@sophonz/redaction 0.0.2
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/LICENSE +15 -0
- package/README-ko.md +191 -0
- package/README.md +191 -0
- package/build/attributes.d.ts +3 -0
- package/build/attributes.d.ts.map +1 -0
- package/build/attributes.js +170 -0
- package/build/index.d.ts +4 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +10 -0
- package/build/types.d.ts +21 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/url.d.ts +6 -0
- package/build/url.d.ts.map +1 -0
- package/build/url.js +164 -0
- package/build/version.d.ts +2 -0
- package/build/version.d.ts.map +1 -0
- package/build/version.js +4 -0
- package/package.json +30 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Copyright (c) 2024-2025 INOBS Inc. All rights reserved.
|
|
2
|
+
|
|
3
|
+
This software and associated documentation files (the "Software") are the proprietary property of INOBS Inc. and are protected by copyright and other intellectual property laws.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, on a limited basis, to use, copy, modify, or distribute the Software only within the scope of a separate written license agreement with INOBS Inc.
|
|
6
|
+
|
|
7
|
+
Unless otherwise agreed to in writing, the Software shall not be used, reproduced, distributed, sublicensed, or disclosed, in whole or in part, to any third party.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT.
|
|
10
|
+
|
|
11
|
+
For license inquiries, please contact:
|
|
12
|
+
|
|
13
|
+
INOBS Inc.
|
|
14
|
+
Email: [contact@sophonz.ai]
|
|
15
|
+
Website: https://inobs.io
|
package/README-ko.md
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# @sophonz/redaction
|
|
2
|
+
|
|
3
|
+
[English](./README.md)
|
|
4
|
+
|
|
5
|
+
Sophonz 브라우저 SDK를 위한 마스킹 기본기. **URL 새니타이징**과 **속성 스크러빙** 두 부분으로 구성됩니다.
|
|
6
|
+
|
|
7
|
+
URL 쪽은 URL이 스팬, 로그 레코드, 내보내기 속성에 기록되기 전에 `user:password@` 자격 증명과 널리 알려진 민감 쿼리 파라미터의 값을 제거합니다.
|
|
8
|
+
|
|
9
|
+
OpenTelemetry 브라우저 navigation instrumentation의 `defaultSanitizeUrl`을 이식하고, 여기에 Embrace의 `additionalQueryParamsToScrub` 사용성을 더했습니다.
|
|
10
|
+
|
|
11
|
+
런타임 의존성이 없습니다.
|
|
12
|
+
|
|
13
|
+
Sophonz OpenTelemetry 제품군의 일부입니다.
|
|
14
|
+
|
|
15
|
+
## 설치
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
bun add @sophonz/redaction
|
|
19
|
+
# 또는
|
|
20
|
+
pnpm add @sophonz/redaction
|
|
21
|
+
# 또는
|
|
22
|
+
npm install @sophonz/redaction
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## 사용법
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { defaultSanitizeUrl } from '@sophonz/redaction';
|
|
29
|
+
|
|
30
|
+
defaultSanitizeUrl('https://api.example.com/v1/me?token=abc&page=2');
|
|
31
|
+
// 'https://api.example.com/v1/me?token=REDACTED&page=2'
|
|
32
|
+
|
|
33
|
+
defaultSanitizeUrl('https://alice:hunter2@api.example.com/v1/me');
|
|
34
|
+
// 'https://REDACTED:REDACTED@api.example.com/v1/me'
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
기본 목록 위에 파라미터를 추가하려면:
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { createSanitizeUrl } from '@sophonz/redaction';
|
|
41
|
+
|
|
42
|
+
const sanitizeUrl = createSanitizeUrl({
|
|
43
|
+
additionalQueryParamsToScrub: ['x-api-key', 'sig'],
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
sanitizeUrl('https://api.example.com/v1?x-api-key=abc&password=p&page=2');
|
|
47
|
+
// 'https://api.example.com/v1?x-api-key=REDACTED&password=REDACTED&page=2'
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## API
|
|
51
|
+
|
|
52
|
+
### `defaultSanitizeUrl(url: string): string`
|
|
53
|
+
|
|
54
|
+
자격 증명과 기본 19개 파라미터를 마스킹합니다. 예외를 던지지 않습니다.
|
|
55
|
+
|
|
56
|
+
### `createSanitizeUrl(options?: SanitizeUrlOptions): SanitizeUrl`
|
|
57
|
+
|
|
58
|
+
sanitizer를 생성합니다. 옵션 없이 호출하면 `defaultSanitizeUrl`과 동일합니다.
|
|
59
|
+
|
|
60
|
+
| 옵션 | 타입 | 기본값 | 의미 |
|
|
61
|
+
|---|---|---|---|
|
|
62
|
+
| `additionalQueryParamsToScrub` | `readonly string[]` | `[]` | 기본 목록에 *더해서* 마스킹할 이름 |
|
|
63
|
+
| `queryParamsToScrub` | `readonly string[]` | 기본 19개 | 기본 목록을 통째로 교체. 위 옵션을 우선 사용 |
|
|
64
|
+
| `redactCredentials` | `boolean` | `true` | authority의 `user:password@` 마스킹 |
|
|
65
|
+
| `scrubFragment` | `boolean` | `true` | 프래그먼트의 파라미터도 검사 |
|
|
66
|
+
|
|
67
|
+
### `DEFAULT_QUERY_PARAMS_TO_SCRUB: readonly string[]`
|
|
68
|
+
|
|
69
|
+
동결된 기본 19개 이름: `password`, `passwd`, `secret`, `api_key`, `apikey`, `auth`, `authorization`, `token`, `access_token`, `refresh_token`, `jwt`, `session`, `sessionid`, `key`, `private_key`, `client_secret`, `client_id`, `signature`, `hash`.
|
|
70
|
+
|
|
71
|
+
### `REDACTED: 'REDACTED'`
|
|
72
|
+
|
|
73
|
+
마스킹된 값을 대체하는 표식입니다.
|
|
74
|
+
|
|
75
|
+
### `type SanitizeUrl = (url: string) => string`
|
|
76
|
+
|
|
77
|
+
instrumentation이 받는 훅 시그니처입니다.
|
|
78
|
+
|
|
79
|
+
## 동작
|
|
80
|
+
|
|
81
|
+
**키가 아니라 값을 대체합니다.** `?token=abc`는 `?token=REDACTED`가 됩니다. 키를 지우면 비밀 값이 있었다는 사실 자체가 사라지는데, 이는 텔레메트리를 보는 운영자가 가장 알아야 할 정보입니다. OpenTelemetry `url.query` 가이드도 마스킹된 키를 유지하도록 권고합니다.
|
|
82
|
+
|
|
83
|
+
**파라미터 이름은 대소문자를 구분하지 않고 정확히 일치할 때만 매칭합니다.** `?Token=`과 `?TOKEN=`은 마스킹되지만 `?tokenizer=`, `?keyword=`, `?monkey=`는 그대로 둡니다. 여기서 부분 문자열 매칭은 치명적입니다. 기본 목록에는 `key`, `auth`, `hash`, `session` 같은 짧고 일반적인 단어가 있어 수많은 무해한 파라미터 이름 안에 등장하며, 과잉 마스킹은 되돌릴 수단이 없습니다. `api-key` 같은 변형은 `additionalQueryParamsToScrub`로 처리합니다.
|
|
84
|
+
|
|
85
|
+
이름은 `+`를 공백으로 바꾸고 퍼센트 이스케이프를 해석한 뒤 앞뒤 공백을 제거해서 비교하므로 `?%74oken=`과 `?%20token=` 모두 마스킹됩니다.
|
|
86
|
+
|
|
87
|
+
**같은 이름이 여러 번 나오면 각각 마스킹합니다.** `?token=a&token=b`는 하나로 합쳐지지 않고 `?token=REDACTED&token=REDACTED`가 됩니다. 반복을 잃으면 실제 요청의 모습이 왜곡됩니다.
|
|
88
|
+
|
|
89
|
+
**프래그먼트도 검사합니다.** OAuth 2.0 implicit grant는 `access_token`이 서버에 도달하지 않도록 일부러 프래그먼트로 전달하므로, 프래그먼트는 실제로 비밀 값이 존재하는 자리입니다. `#access_token=…`과 `#/checkout?token=…` 형태의 해시 라우트를 모두 처리합니다. 파라미터 목록이 아닌 프래그먼트(`#installation`, `#/orders/42`)는 `name=value` 쌍이 없으므로 그대로 통과합니다. `scrubFragment: false`로 끌 수 있습니다.
|
|
90
|
+
|
|
91
|
+
**그 밖에는 아무것도 바꾸지 않습니다.** 구현은 문자열 기반이며 `URL`이나 `URLSearchParams`를 거치지 않으므로 경로, 인코딩, 파라미터 순서, 호스트 대소문자, 기본 포트, 마지막 슬래시 유무가 바이트 단위로 보존됩니다. `URL` 왕복은 마스킹 여부와 무관하게 `http://x.test`를 `http://x.test/`로 바꾸고 `:80`을 지우고 호스트를 소문자로 만들고 쿼리 전체를 재인코딩(`%20` → `+`)하는데, 이는 애플리케이션이 실제로 요청한 URL과 대조하기 어렵게 만듭니다.
|
|
92
|
+
|
|
93
|
+
**예외를 던지지 않으며, 파싱 불가 입력이라는 개념이 없습니다.** 파싱을 하지 않으므로 파싱에 실패할 수 없습니다. 상대 URL(`/api?token=…`), 프로토콜 상대 URL, `data:`/`blob:` URI, 잘못된 문자열이 모두 같은 경로로 처리되며 그래도 마스킹됩니다. 두 가지 경계는 명시적으로 정의했습니다.
|
|
94
|
+
|
|
95
|
+
- **문자열이 아닌 인자**는 `''`를 반환합니다. 이는 마스킹 실패가 아니라 호출자의 타입 오류입니다. `undefined`에는 비밀 값이 없음이 자명하고, 임의 객체를 문자열로 강제 변환하면 검사하지 않은 `toString`을 호출하게 됩니다.
|
|
96
|
+
- **내부 실패**는 구조상 도달할 수 없지만 방어해 두었으며, 문자열 `REDACTED`를 반환합니다. 입력을 그대로 돌려주는 것은 안전하지 않습니다. 그 입력이 바로 마스킹에 실패한 대상이기 때문입니다. `url.full` 속성값이 정확히 `REDACTED`라면 sanitizer가 해당 URL을 포기했다는 뜻입니다.
|
|
97
|
+
|
|
98
|
+
## 상위 OpenTelemetry 구현과의 차이
|
|
99
|
+
|
|
100
|
+
의도적으로 바로잡은 세 가지이며 모두 테스트로 덮여 있습니다.
|
|
101
|
+
|
|
102
|
+
| 상위 구현 | 이 패키지 |
|
|
103
|
+
|---|---|
|
|
104
|
+
| `searchParams.has(param)`이 대소문자를 구분해 `URL` 경로에서는 `?Token=`이 살아남음 (정규식 폴백은 구분하지 않아 입력에 따라 동작이 달라짐) | 모든 입력에서 대소문자 무시 |
|
|
105
|
+
| `searchParams.set(param, …)`이 중복 파라미터를 합쳐 `?token=a&token=b`가 `?token=REDACTED`가 됨 | 각 항목을 제자리에서 마스킹 |
|
|
106
|
+
| `new URL(url)`이 상대 URL에서 예외를 던져 호출마다 19개의 `RegExp`를 만드는 폴백으로 감; `URL` 경로는 URL 전체를 재인코딩·정규화 | 문자열 1회 순회, 호출당 `RegExp` 생성 없음, 정규화 없음 |
|
|
107
|
+
|
|
108
|
+
## 속성 스크러빙 (attribute scrubbing)
|
|
109
|
+
|
|
110
|
+
위의 URL 새니타이저는 URL을 다룹니다. `createAttributeScrubber`는 **속성**을 다룹니다 — Embrace의 `attributeScrubbers`에 해당하며, 모든 스팬과 모든 로그 레코드에 키 단위로 적용되므로 URL이 아니라 속성에 담긴 비밀 값도 잡아냅니다.
|
|
111
|
+
|
|
112
|
+
`SophonzSpanAttributeScrubbingProcessor`(`@sophonz/span-processors`)와 `SophonzLogAttributeScrubbingProcessor`(`@sophonz/log-processors`)가 이 함수를 사용하며, SDK의 `attributeScrubbers` 옵션으로 설정합니다.
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
import { createAttributeScrubber } from '@sophonz/redaction';
|
|
116
|
+
|
|
117
|
+
const scrub = createAttributeScrubber([
|
|
118
|
+
{ keys: ['app.user.email'] }, // -> 'REDACTED'
|
|
119
|
+
{ keyPattern: /^http\.request\.header\./ }, // -> 'REDACTED'
|
|
120
|
+
{
|
|
121
|
+
keys: ['app.query'], // 값 변환 (마스킹 아님)
|
|
122
|
+
scrub: (_key, value) =>
|
|
123
|
+
typeof value === 'string' ? value.slice(0, 64) : value,
|
|
124
|
+
},
|
|
125
|
+
{ keys: ['app.internal'], scrub: () => undefined }, // 속성 제거
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
const attributes = { 'app.user.email': 'a@b.test', 'app.span.type': 'route' };
|
|
129
|
+
scrub(attributes); // true
|
|
130
|
+
// attributes === { 'app.user.email': 'REDACTED', 'app.span.type': 'route' }
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### `createAttributeScrubber(scrubbers?, options?): ScrubAttributes`
|
|
134
|
+
|
|
135
|
+
규칙 목록을 하나의 함수로 컴파일합니다. 이 함수는 속성 객체를 **제자리에서** 수정하고 변경 여부를 반환하며, 예외를 던지지 않습니다.
|
|
136
|
+
|
|
137
|
+
빈 목록·미지정·전부 유효하지 않은 목록은 공유 no-op으로 컴파일됩니다 — 매번 **동일한 함수 객체**이므로, 설정하지 않은 고객은 클로저·순회·복사 비용을 전혀 지불하지 않습니다.
|
|
138
|
+
|
|
139
|
+
`options.onError(message, error?)`로 설정 오류와 스크러버 실패를 보고합니다. 이 패키지는 `@opentelemetry/api`에 의존하지 않아 `diag`에 직접 접근할 수 없으므로, 이를 감싸는 프로세서가 주입합니다. 기본값은 no-op입니다 — 편집기(redactor)가 요청받지 않은 채 고객 콘솔에 쓰는 일은 없어야 합니다.
|
|
140
|
+
|
|
141
|
+
### 스크러버의 형태, 그리고 `(key, value) => value | undefined`가 아닌 이유
|
|
142
|
+
|
|
143
|
+
스크러버는 **매처 + 변환**입니다.
|
|
144
|
+
|
|
145
|
+
| 필드 | 용도 |
|
|
146
|
+
|---|---|
|
|
147
|
+
| `keys?: readonly string[]` | 정확히 일치하는 속성 키 (대소문자 구분) |
|
|
148
|
+
| `keyPattern?: RegExp \| readonly RegExp[]` | 키에 대해 테스트할 패턴 |
|
|
149
|
+
| `shouldScrub?: (key) => boolean` | 임의 술어(predicate). 탈출구 |
|
|
150
|
+
| `scrub?: (key, value) => value \| undefined` | 치환할 값. 생략하면 `'REDACTED'`, `undefined`를 반환하면 속성 제거 |
|
|
151
|
+
|
|
152
|
+
매처는 최소 하나가 필요합니다. 매처가 없는 규칙은 아무것도 매칭하지 않으므로, 조용히 동작하는 척하지 않고 생성 시점에 `onError`로 보고한 뒤 폐기합니다.
|
|
153
|
+
|
|
154
|
+
단일 함수 형태를 택하지 않은 이유는 두 가지입니다.
|
|
155
|
+
|
|
156
|
+
**"내 담당이 아님"을 표현할 수 없습니다.** `(key, value) => value | undefined` 스크러버는 모든 속성에 대해 호출되므로 세 가지 결과(그대로 두기·치환·삭제)가 필요한데, `undefined` 하나로는 그중 하나만 표현할 수 있습니다. 어느 쪽 의미를 부여하든 나머지는 표현 불가능해지며, 실수했을 때의 결과는 "받은 값을 되돌려주는 것을 잊은 스크러버가 모든 스팬을 조용히 비우는 것"입니다. 분리된 형태에는 잘못될 상태 자체가 없습니다.
|
|
157
|
+
|
|
158
|
+
**저렴하게 만들 수 없습니다.** 이 코드는 모든 스팬과 모든 로그 레코드의 모든 속성마다 실행됩니다. 매처가 *데이터*이면 모든 규칙의 정확한 키를 하나의 공유 `Set`으로, 모든 패턴을 미리 컴파일한 하나의 `RegExp` 목록으로 합칠 수 있어, 아무도 관심 없는 속성은 규칙 개수와 무관하게 `Set` 조회 한 번으로 끝납니다 — 클로저도, 할당도, `try`/`catch` 진입도 없습니다. 매처가 함수이면 모든 속성마다 모든 규칙을 호출해야 합니다. (`shouldScrub`을 선언하면 그 규칙은 빠른 경로에서 빠지므로, 선언적 매처를 기본으로 문서화합니다.)
|
|
159
|
+
|
|
160
|
+
패턴은 한 번만 컴파일합니다. 호출자의 `RegExp` 사본에서 `g`·`y` 플래그를 제거하는데, `RegExp.prototype.test`가 전역/스티키 패턴에서 `lastIndex`를 전진시켜 두 개 중 하나만 매칭하는 문제를 막기 위해서입니다. 호출자의 객체는 절대 변경하지 않습니다.
|
|
161
|
+
|
|
162
|
+
매칭되는 모든 규칙이 선언 순서대로 값을 이어받아 실행되므로, 잘라내는 규칙 뒤에 해시하는 규칙을 두면 읽는 순서대로 합성됩니다.
|
|
163
|
+
|
|
164
|
+
### 기본 스크러버는 의도적으로 없습니다
|
|
165
|
+
|
|
166
|
+
URL 쪽은 19개 파라미터 이름과 함께 기본 보안 설정으로 출시됩니다. 속성 쪽은 비어 있습니다. 이 비대칭은 의도된 것입니다.
|
|
167
|
+
|
|
168
|
+
**속성 키는 구조화된 네임스페이스이고, 쿼리 파라미터 이름은 아닙니다.** `?token=`은 작성자가 자유롭게 정하는 텍스트이고 거기서 `token`은 정말로 토큰을 뜻합니다. 반면 속성 키는 `http.request.header.authorization`, `app.screen.name`, `user.journey.id`입니다. 이 SDK가 내보내는 속성 중 이름이 그냥 `key`, `hash`, `auth`, `session`인 것은 없으므로, 같은 19개 이름을 그대로 옮겨와도 거의 아무것도 매칭하지 않고 보안 이득도 거의 없습니다.
|
|
169
|
+
|
|
170
|
+
**이를 보완하려고 매칭을 느슨하게 하는 순간 위험해집니다.** `key`는 `service.key`의 부분 문자열이고, `session`은 `session.id`의 부분 문자열입니다 — 이 SDK에서 가장 핵심적인 속성이며, 모든 대시보드가 조인하고 ClickHouse의 명명 컬럼으로 승격되는 값입니다. 부분 문자열 기반 기본값은 이들을 조용히 0으로 만듭니다.
|
|
171
|
+
|
|
172
|
+
**과잉 편집은 조용하고 되돌릴 수 없습니다.** 마스킹된 속성은 컬렉터에 아무 흔적도 남기지 않고, 복원 수단도 없으며, 그런 일이 있었다는 신호조차 없습니다. 반대로 편집 누락은 최소한 데이터에서 보입니다. 외부에서 감사할 수 없는 기본값이라면, 요청받지 않은 것은 편집하지 않는 쪽이 안전합니다.
|
|
173
|
+
|
|
174
|
+
**정말 위험한 기본 표면은 이미 덮여 있습니다.** URL은 기본으로 편집됩니다. 실제로 비밀 값을 담는 속성은 고객이 직접 넣은 것들(`globalAttributes`, 콘솔 계측의 객체 전개, `data-sophonz-*`)이고, 그 키 이름을 아는 사람은 정확히 고객입니다. Embrace가 `attributeScrubbers`를 비운 채 출시하는 이유도 같습니다.
|
|
175
|
+
|
|
176
|
+
즉, 아무것도 설정하지 않으면 이 프로세서를 설치한 적 없는 것과 바이트 단위로 동일한 텔레메트리가 전송됩니다.
|
|
177
|
+
|
|
178
|
+
### 고객의 스크러버가 예외를 던졌을 때
|
|
179
|
+
|
|
180
|
+
스크러버는 고객 페이지의 핫 패스에서 실행되는 임의 코드입니다. 모든 호출을 감싸며, 두 가지 실패를 의도적으로 다르게 다룹니다.
|
|
181
|
+
|
|
182
|
+
| 예외 위치 | 결과 | 이유 |
|
|
183
|
+
|---|---|---|
|
|
184
|
+
| `scrub(key, value)` | 값이 `'REDACTED'`가 됩니다. 규칙은 계속 활성 상태입니다. 1회 보고. | **닫힌 실패(fail closed).** 매처가 매칭됐다는 것은 고객이 이 키에 비밀 값이 있을 수 있다고 알려준 것입니다. 변환 실패는 값의 안전 여부에 대해 아무것도 말해주지 않습니다. `'REDACTED'`는 키 삭제와 정확히 동일한 수준으로 유출을 막으면서, 삭제와 달리 "값이 있었고 억제되었다"는 흔적을 남깁니다 — 조용히 사라진 키는 애초에 설정된 적 없는 키와 구분되지 않고, 그 이름을 그대로 매칭하는 대시보드를 0으로 만들 수 있습니다. |
|
|
185
|
+
| `shouldScrub(key)` | 속성을 그대로 둡니다. 해당 규칙은 페이지 수명 동안 **비활성화**됩니다. 1회 보고. | 예외를 던진 술어는 이 키에 대해 아무것도 알려주지 않았습니다. 예외를 매칭으로 취급하면 모든 스팬의 모든 속성을 끌 방법 없이 편집하게 되고, 비매칭으로 취급하면 편집이 누락되지만 그 대상은 명백히 고장 난 규칙 하나뿐이며 보고도 됩니다. 계속 호출하면 속성마다 예외 비용을 영원히 지불하게 됩니다. |
|
|
186
|
+
|
|
187
|
+
보고는 규칙당 1회만 발생하므로, 모든 스팬의 모든 속성에서 실패하는 규칙이 콘솔을 도배할 수 없습니다. 고장 난 규칙이 다른 규칙을 비활성화하지도 않습니다.
|
|
188
|
+
|
|
189
|
+
## 범위
|
|
190
|
+
|
|
191
|
+
이 패키지는 전송·SDK에 중립적입니다. `@opentelemetry/api`에 의존하지 않으며 스팬이나 로그 레코드에 대해 아무것도 모릅니다. 파이프라인 배선 — 프로세서 체인의 어느 지점에서 실행되는지, 그 시점에 무엇을 변경할 수 있는지 — 은 `@sophonz/span-processors`와 `@sophonz/log-processors`에 있습니다.
|
package/README.md
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# @sophonz/redaction
|
|
2
|
+
|
|
3
|
+
[한국어](./README-ko.md)
|
|
4
|
+
|
|
5
|
+
Redaction primitives for the Sophonz browser SDK, in two halves: **URL sanitizing** and **attribute scrubbing**.
|
|
6
|
+
|
|
7
|
+
The URL half strips `user:password@` credentials and the values of well-known sensitive query parameters before a URL is written to a span, a log record or an exported attribute.
|
|
8
|
+
|
|
9
|
+
Ported from the OpenTelemetry browser navigation instrumentation's `defaultSanitizeUrl`, with Embrace's `additionalQueryParamsToScrub` ergonomic layered on top.
|
|
10
|
+
|
|
11
|
+
No runtime dependencies.
|
|
12
|
+
|
|
13
|
+
Part of the Sophonz OpenTelemetry suite.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
bun add @sophonz/redaction
|
|
19
|
+
# or
|
|
20
|
+
pnpm add @sophonz/redaction
|
|
21
|
+
# or
|
|
22
|
+
npm install @sophonz/redaction
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { defaultSanitizeUrl } from '@sophonz/redaction';
|
|
29
|
+
|
|
30
|
+
defaultSanitizeUrl('https://api.example.com/v1/me?token=abc&page=2');
|
|
31
|
+
// 'https://api.example.com/v1/me?token=REDACTED&page=2'
|
|
32
|
+
|
|
33
|
+
defaultSanitizeUrl('https://alice:hunter2@api.example.com/v1/me');
|
|
34
|
+
// 'https://REDACTED:REDACTED@api.example.com/v1/me'
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
To redact your own parameters on top of the defaults:
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { createSanitizeUrl } from '@sophonz/redaction';
|
|
41
|
+
|
|
42
|
+
const sanitizeUrl = createSanitizeUrl({
|
|
43
|
+
additionalQueryParamsToScrub: ['x-api-key', 'sig'],
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
sanitizeUrl('https://api.example.com/v1?x-api-key=abc&password=p&page=2');
|
|
47
|
+
// 'https://api.example.com/v1?x-api-key=REDACTED&password=REDACTED&page=2'
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## API
|
|
51
|
+
|
|
52
|
+
### `defaultSanitizeUrl(url: string): string`
|
|
53
|
+
|
|
54
|
+
Redacts credentials and the 19 default parameters. Never throws.
|
|
55
|
+
|
|
56
|
+
### `createSanitizeUrl(options?: SanitizeUrlOptions): SanitizeUrl`
|
|
57
|
+
|
|
58
|
+
Builds a sanitizer. `createSanitizeUrl()` with no options is equivalent to `defaultSanitizeUrl`.
|
|
59
|
+
|
|
60
|
+
| Option | Type | Default | Meaning |
|
|
61
|
+
|---|---|---|---|
|
|
62
|
+
| `additionalQueryParamsToScrub` | `readonly string[]` | `[]` | Names redacted *in addition to* the defaults |
|
|
63
|
+
| `queryParamsToScrub` | `readonly string[]` | the 19 defaults | Replaces the default list outright. Prefer the option above |
|
|
64
|
+
| `redactCredentials` | `boolean` | `true` | Redact `user:password@` in the authority |
|
|
65
|
+
| `scrubFragment` | `boolean` | `true` | Also scan the fragment for parameters |
|
|
66
|
+
|
|
67
|
+
### `DEFAULT_QUERY_PARAMS_TO_SCRUB: readonly string[]`
|
|
68
|
+
|
|
69
|
+
The 19 default names, frozen: `password`, `passwd`, `secret`, `api_key`, `apikey`, `auth`, `authorization`, `token`, `access_token`, `refresh_token`, `jwt`, `session`, `sessionid`, `key`, `private_key`, `client_secret`, `client_id`, `signature`, `hash`.
|
|
70
|
+
|
|
71
|
+
### `REDACTED: 'REDACTED'`
|
|
72
|
+
|
|
73
|
+
The marker substituted for every redacted value.
|
|
74
|
+
|
|
75
|
+
### `type SanitizeUrl = (url: string) => string`
|
|
76
|
+
|
|
77
|
+
The hook signature instrumentations accept.
|
|
78
|
+
|
|
79
|
+
## Behaviour
|
|
80
|
+
|
|
81
|
+
**The value is replaced, never the key.** `?token=abc` becomes `?token=REDACTED`. Deleting the key would hide that a secret was ever present, which is the thing an operator reading the telemetry most needs to know. This follows the OpenTelemetry `url.query` guidance that a redacted key SHOULD be preserved.
|
|
82
|
+
|
|
83
|
+
**Parameter names match exactly, case-insensitively.** `?Token=` and `?TOKEN=` are redacted; `?tokenizer=`, `?keyword=` and `?monkey=` are not. Substring matching would be catastrophic here — the default list contains short generic words (`key`, `auth`, `hash`, `session`) that appear inside a great many innocent parameter names, and over-redaction has no off switch. `additionalQueryParamsToScrub` is the supported way to cover variants such as `api-key`.
|
|
84
|
+
|
|
85
|
+
Names are compared after decoding `+` to a space and resolving percent escapes, and after trimming surrounding whitespace, so `?%74oken=` and `?%20token=` are both redacted.
|
|
86
|
+
|
|
87
|
+
**Every occurrence is redacted independently.** `?token=a&token=b` becomes `?token=REDACTED&token=REDACTED` rather than collapsing to one pair; losing the repetition would misrepresent the request.
|
|
88
|
+
|
|
89
|
+
**The fragment is scanned.** The OAuth 2.0 implicit grant delivers `access_token` in the fragment precisely so it never reaches a server, which makes the fragment a place real secrets live. Both `#access_token=…` and hash routes carrying `#/checkout?token=…` are covered. A fragment that is not a parameter list (`#installation`, `#/orders/42`) contains no `name=value` pair and passes through untouched. Set `scrubFragment: false` to opt out.
|
|
90
|
+
|
|
91
|
+
**Nothing else changes.** The implementation is string-based and does not round-trip through `URL` or `URLSearchParams`, so the path, encoding, parameter order, host case, default port and trailing-slash-or-not all come back byte-for-byte identical. A `URL` round-trip would rewrite `http://x.test` to `http://x.test/`, drop `:80`, lower-case the host, and re-encode the whole query string (`%20` to `+`) whether or not anything was redacted — differences that make a URL harder to match against what the application actually requested.
|
|
92
|
+
|
|
93
|
+
**It never throws, and there is no unparseable input.** Nothing is parsed, so nothing can fail to parse: relative URLs (`/api?token=…`), protocol-relative URLs, `data:` and `blob:` URIs, and malformed strings are all handled by the same code path and are still redacted. Two edge cases are defined explicitly:
|
|
94
|
+
|
|
95
|
+
- A **non-string** argument returns `''`. This is a caller type error, not a redaction failure; there is provably no secret in `undefined`, and coercing an arbitrary object would mean invoking a `toString` we have not inspected.
|
|
96
|
+
- An **internal failure** — unreachable by construction, but guarded — returns the bare string `REDACTED`. Returning the input unchanged would be unsafe, because the input is exactly what we failed to redact. A `url.full` attribute equal to exactly `REDACTED` means the sanitizer bailed on that URL.
|
|
97
|
+
|
|
98
|
+
## Differences from the upstream OpenTelemetry implementation
|
|
99
|
+
|
|
100
|
+
Three deliberate corrections, all covered by tests:
|
|
101
|
+
|
|
102
|
+
| Upstream | Here |
|
|
103
|
+
|---|---|
|
|
104
|
+
| `searchParams.has(param)` is case-**sensitive**, so `?Token=` survives on the `URL` path (the regex fallback is case-insensitive, so behaviour differs by input) | Case-insensitive on every input |
|
|
105
|
+
| `searchParams.set(param, …)` collapses repeated parameters, so `?token=a&token=b` becomes `?token=REDACTED` | Each occurrence redacted in place |
|
|
106
|
+
| `new URL(url)` throws on relative URLs, falling back to building 19 `RegExp` objects per call; the `URL` path also re-encodes and normalises the whole URL | One string pass, no `RegExp` construction per call, no normalisation |
|
|
107
|
+
|
|
108
|
+
## Attribute scrubbing
|
|
109
|
+
|
|
110
|
+
The URL sanitizer above covers URLs. `createAttributeScrubber` covers **attributes** — Embrace's `attributeScrubbers`, per-key redaction applied to every span and every log record, so a secret that lands in an attribute rather than a URL is caught too.
|
|
111
|
+
|
|
112
|
+
It is consumed by `SophonzSpanAttributeScrubbingProcessor` (`@sophonz/span-processors`) and `SophonzLogAttributeScrubbingProcessor` (`@sophonz/log-processors`), and configured through the SDK's `attributeScrubbers` option.
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
import { createAttributeScrubber } from '@sophonz/redaction';
|
|
116
|
+
|
|
117
|
+
const scrub = createAttributeScrubber([
|
|
118
|
+
{ keys: ['app.user.email'] }, // -> 'REDACTED'
|
|
119
|
+
{ keyPattern: /^http\.request\.header\./ }, // -> 'REDACTED'
|
|
120
|
+
{
|
|
121
|
+
keys: ['app.query'], // transform, not blank
|
|
122
|
+
scrub: (_key, value) =>
|
|
123
|
+
typeof value === 'string' ? value.slice(0, 64) : value,
|
|
124
|
+
},
|
|
125
|
+
{ keys: ['app.internal'], scrub: () => undefined }, // remove the attribute
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
const attributes = { 'app.user.email': 'a@b.test', 'app.span.type': 'route' };
|
|
129
|
+
scrub(attributes); // true
|
|
130
|
+
// attributes === { 'app.user.email': 'REDACTED', 'app.span.type': 'route' }
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### `createAttributeScrubber(scrubbers?, options?): ScrubAttributes`
|
|
134
|
+
|
|
135
|
+
Compiles a list of rules into one function that edits an attribute bag **in place** and returns whether anything changed. Never throws.
|
|
136
|
+
|
|
137
|
+
An empty, absent or entirely invalid list compiles to a shared no-op — literally the same function object every time, so an unconfigured SDK is not paying for a closure, an iteration or a copy.
|
|
138
|
+
|
|
139
|
+
`options.onError(message, error?)` receives misconfiguration and scrubber failures. This package has no dependency on `@opentelemetry/api`, so it cannot reach `diag` itself; the processors that wrap it pass one in. It defaults to a no-op — a redactor must never write to a customer's console uninvited.
|
|
140
|
+
|
|
141
|
+
### The scrubber shape, and why it is not `(key, value) => value | undefined`
|
|
142
|
+
|
|
143
|
+
A scrubber is a **matcher plus a transform**:
|
|
144
|
+
|
|
145
|
+
| Field | Purpose |
|
|
146
|
+
|---|---|
|
|
147
|
+
| `keys?: readonly string[]` | Exact attribute keys, case-sensitive |
|
|
148
|
+
| `keyPattern?: RegExp \| readonly RegExp[]` | Patterns tested against the key |
|
|
149
|
+
| `shouldScrub?: (key) => boolean` | Arbitrary predicate; the escape hatch |
|
|
150
|
+
| `scrub?: (key, value) => value \| undefined` | Replacement value. Omit for `'REDACTED'`; return `undefined` to remove the attribute |
|
|
151
|
+
|
|
152
|
+
At least one matcher is required. A rule with none matches nothing and is dropped at construction with an `onError` report, rather than silently pretending to work.
|
|
153
|
+
|
|
154
|
+
The single-function alternative was rejected for two reasons.
|
|
155
|
+
|
|
156
|
+
**It has no way to say "not mine".** A `(key, value) => value | undefined` scrubber is called for every attribute, so it needs three outcomes — leave it, replace it, delete it — and `undefined` can only encode one. Whichever meaning it is given, the other becomes inexpressible; and the failure mode of getting it wrong is a scrubber that forgets to return the value it was handed, which silently empties every span. The split has no such state to get wrong.
|
|
157
|
+
|
|
158
|
+
**It cannot be made cheap.** This runs on every attribute of every span and every log record. With the matcher as *data*, all rules' exact keys compile into one shared `Set` and all their patterns into one shared, pre-compiled `RegExp` list, so an attribute nobody cares about costs one set lookup regardless of how many rules are configured — no closure, no allocation, no `try`/`catch` entered. With the matcher as a function, every rule must be invoked for every attribute. (Declaring a `shouldScrub` opts that rule out of the fast path, which is why the declarative matchers are the documented default.)
|
|
159
|
+
|
|
160
|
+
Patterns are compiled once. A `g` or `y` flag is stripped from a copy of the caller's `RegExp`, because `RegExp.prototype.test` advances `lastIndex` on a global or sticky pattern and would otherwise match only every other attribute. The caller's object is never mutated.
|
|
161
|
+
|
|
162
|
+
Every matching rule runs, in declaration order, threading the value through — so a truncating rule followed by a hashing rule composes the way it reads.
|
|
163
|
+
|
|
164
|
+
### There are no default scrubbers, deliberately
|
|
165
|
+
|
|
166
|
+
The URL half ships secure-by-default with 19 parameter names. The attribute half ships empty. That asymmetry is intentional.
|
|
167
|
+
|
|
168
|
+
**Attribute keys are a structured namespace; query parameter names are not.** `?token=` is free-form, author-chosen text where `token` really does mean a token. Attribute keys are `http.request.header.authorization`, `app.screen.name`, `user.journey.id`. Nothing in this SDK emits an attribute literally named `key`, `hash`, `auth` or `session`, so the same 19 names transplanted here would match almost nothing and buy almost no security.
|
|
169
|
+
|
|
170
|
+
**Loosening the match to compensate is where it turns dangerous.** `key` is a substring of `service.key`; `session` is a substring of `session.id` — the single most load-bearing attribute in this SDK, joined on by every dashboard and promoted to a named ClickHouse column. A substring default would silently zero them.
|
|
171
|
+
|
|
172
|
+
**Over-redaction is silent and has no off switch.** A blanked attribute leaves no trace at the collector, there is no un-redact, and nothing signals that it happened. Under-redaction is at least visible in the data. Given a default that cannot be audited from the outside, the safe direction is to redact nothing you were not asked to.
|
|
173
|
+
|
|
174
|
+
**The dangerous default surface is already covered.** URLs redact by default. The attributes that carry secrets in practice are ones the customer put there — `globalAttributes`, the console instrumentation's object spread, `data-sophonz-*` — and the customer is exactly who knows those key names. Embrace ships its `attributeScrubbers` empty for the same reason.
|
|
175
|
+
|
|
176
|
+
So: configure nothing and your telemetry is byte-identical to having never installed the processor.
|
|
177
|
+
|
|
178
|
+
### What happens when a customer's scrubber throws
|
|
179
|
+
|
|
180
|
+
A scrubber is arbitrary code on the hot path of someone's page. Every invocation is wrapped, and the two failures are treated differently on purpose:
|
|
181
|
+
|
|
182
|
+
| Throws in | Outcome | Why |
|
|
183
|
+
|---|---|---|
|
|
184
|
+
| `scrub(key, value)` | Value becomes `'REDACTED'`. The rule stays active. Reported once. | **Fail closed.** The matcher fired, so the customer has told us this key may carry a secret; the transform failing says nothing about whether the value is safe. `'REDACTED'` is exactly as leak-proof as deleting the key, and unlike deleting it, leaves visible evidence that a value was there and was suppressed — a silently vanished key is indistinguishable from one that was never set, and can zero a dashboard that matches it by literal name. |
|
|
185
|
+
| `shouldScrub(key)` | Attribute left alone. The rule is **disabled** for the rest of the page's life. Reported once. | A predicate that throws has told us nothing about this key. Treating the throw as a match would redact every attribute of every span with no way to switch it off; treating it as a non-match under-redacts, but only for a rule that is provably broken, and it is reported. Continuing to call it would burn CPU on a throw per attribute forever. |
|
|
186
|
+
|
|
187
|
+
Reports are emitted once per rule so a rule that fails on every attribute of every span cannot turn the console into a firehose. A broken rule never disables its siblings.
|
|
188
|
+
|
|
189
|
+
## Scope
|
|
190
|
+
|
|
191
|
+
This package is transport- and SDK-agnostic: it has no dependency on `@opentelemetry/api` and knows nothing about spans or log records. Wiring it into the pipeline — where in the processor chain it runs, and what may be mutated at that point — lives in `@sophonz/span-processors` and `@sophonz/log-processors`.
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { AttributeScrubber, AttributeScrubberOptions, ScrubAttributes } from './types';
|
|
2
|
+
export declare function createAttributeScrubber(scrubbers: readonly AttributeScrubber[] | null | undefined, options?: AttributeScrubberOptions): ScrubAttributes;
|
|
3
|
+
//# sourceMappingURL=attributes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"attributes.d.ts","sourceRoot":"","sources":["../src/attributes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,iBAAiB,EACjB,wBAAwB,EACxB,eAAe,EAEhB,MAAM,SAAS,CAAC;AAyEjB,wBAAgB,uBAAuB,CACrC,SAAS,EAAE,SAAS,iBAAiB,EAAE,GAAG,IAAI,GAAG,SAAS,EAC1D,OAAO,GAAE,wBAA6B,GACrC,eAAe,CA+MjB"}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createAttributeScrubber = createAttributeScrubber;
|
|
4
|
+
const url_1 = require("./url");
|
|
5
|
+
const NOOP_SCRUB = function noopScrubAttributes() {
|
|
6
|
+
return false;
|
|
7
|
+
};
|
|
8
|
+
function stateless(pattern) {
|
|
9
|
+
if (pattern.global || pattern.sticky) {
|
|
10
|
+
return new RegExp(pattern.source, pattern.flags.replace('g', '').replace('y', ''));
|
|
11
|
+
}
|
|
12
|
+
return pattern;
|
|
13
|
+
}
|
|
14
|
+
function matchesAnyPattern(patterns, key) {
|
|
15
|
+
for (let i = 0; i < patterns.length; i++) {
|
|
16
|
+
if (patterns[i].test(key)) {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
function createAttributeScrubber(scrubbers, options = {}) {
|
|
23
|
+
const onError = options.onError;
|
|
24
|
+
if (!scrubbers || scrubbers.length === 0) {
|
|
25
|
+
return NOOP_SCRUB;
|
|
26
|
+
}
|
|
27
|
+
const compiled = [];
|
|
28
|
+
const allKeys = new Set();
|
|
29
|
+
const allPatterns = [];
|
|
30
|
+
let hasPredicate = false;
|
|
31
|
+
for (let i = 0; i < scrubbers.length; i++) {
|
|
32
|
+
const scrubber = scrubbers[i];
|
|
33
|
+
if (!scrubber || typeof scrubber !== 'object') {
|
|
34
|
+
onError === null || onError === void 0 ? void 0 : onError(`attributeScrubbers[${i}] is not an object; ignoring it`);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const entry = { broken: false, reported: false };
|
|
38
|
+
if (scrubber.keys && scrubber.keys.length > 0) {
|
|
39
|
+
const keys = new Set();
|
|
40
|
+
for (let k = 0; k < scrubber.keys.length; k++) {
|
|
41
|
+
const key = scrubber.keys[k];
|
|
42
|
+
if (typeof key === 'string' && key !== '') {
|
|
43
|
+
keys.add(key);
|
|
44
|
+
allKeys.add(key);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (keys.size > 0) {
|
|
48
|
+
entry.keys = keys;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (scrubber.keyPattern) {
|
|
52
|
+
const raw = scrubber.keyPattern instanceof RegExp
|
|
53
|
+
? [scrubber.keyPattern]
|
|
54
|
+
: scrubber.keyPattern;
|
|
55
|
+
const patterns = [];
|
|
56
|
+
for (let p = 0; p < raw.length; p++) {
|
|
57
|
+
if (raw[p] instanceof RegExp) {
|
|
58
|
+
const pattern = stateless(raw[p]);
|
|
59
|
+
patterns.push(pattern);
|
|
60
|
+
allPatterns.push(pattern);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (patterns.length > 0) {
|
|
64
|
+
entry.patterns = patterns;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (typeof scrubber.shouldScrub === 'function') {
|
|
68
|
+
entry.predicate = scrubber.shouldScrub;
|
|
69
|
+
hasPredicate = true;
|
|
70
|
+
}
|
|
71
|
+
if (!entry.keys && !entry.patterns && !entry.predicate) {
|
|
72
|
+
onError === null || onError === void 0 ? void 0 : onError(`attributeScrubbers[${i}] declares no keys, keyPattern or shouldScrub; ` +
|
|
73
|
+
'it would match nothing and has been ignored');
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (typeof scrubber.scrub === 'function') {
|
|
77
|
+
entry.scrub = scrubber.scrub;
|
|
78
|
+
}
|
|
79
|
+
compiled.push(entry);
|
|
80
|
+
}
|
|
81
|
+
if (compiled.length === 0) {
|
|
82
|
+
return NOOP_SCRUB;
|
|
83
|
+
}
|
|
84
|
+
function report(entry, message, error) {
|
|
85
|
+
if (entry.reported) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
entry.reported = true;
|
|
89
|
+
onError === null || onError === void 0 ? void 0 : onError(message, error);
|
|
90
|
+
}
|
|
91
|
+
function matches(entry, key) {
|
|
92
|
+
if (entry.keys !== undefined && entry.keys.has(key)) {
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
if (entry.patterns !== undefined && matchesAnyPattern(entry.patterns, key)) {
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
if (entry.predicate !== undefined) {
|
|
99
|
+
try {
|
|
100
|
+
return entry.predicate(key) === true;
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
entry.broken = true;
|
|
104
|
+
report(entry, 'an attribute scrubber\'s shouldScrub() threw; the scrubber has been ' +
|
|
105
|
+
'disabled and its attributes will NOT be redacted', error);
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
return function scrubAttributes(attributes) {
|
|
112
|
+
if (attributes === null || typeof attributes !== 'object') {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
let changed = false;
|
|
116
|
+
let toDelete;
|
|
117
|
+
for (const key in attributes) {
|
|
118
|
+
if (!Object.prototype.hasOwnProperty.call(attributes, key)) {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (!hasPredicate &&
|
|
122
|
+
!allKeys.has(key) &&
|
|
123
|
+
!matchesAnyPattern(allPatterns, key)) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const original = attributes[key];
|
|
127
|
+
let value = original;
|
|
128
|
+
let remove = false;
|
|
129
|
+
for (let i = 0; i < compiled.length; i++) {
|
|
130
|
+
const entry = compiled[i];
|
|
131
|
+
if (entry.broken || !matches(entry, key)) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
let next;
|
|
135
|
+
if (entry.scrub === undefined) {
|
|
136
|
+
next = url_1.REDACTED;
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
try {
|
|
140
|
+
next = entry.scrub(key, value);
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
report(entry, `an attribute scrubber's scrub() threw for key "${key}"; the ` +
|
|
144
|
+
'value has been replaced with REDACTED', error);
|
|
145
|
+
next = url_1.REDACTED;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (next === undefined) {
|
|
149
|
+
remove = true;
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
value = next;
|
|
153
|
+
}
|
|
154
|
+
if (remove) {
|
|
155
|
+
(toDelete !== null && toDelete !== void 0 ? toDelete : (toDelete = [])).push(key);
|
|
156
|
+
changed = true;
|
|
157
|
+
}
|
|
158
|
+
else if (!Object.is(value, original)) {
|
|
159
|
+
attributes[key] = value;
|
|
160
|
+
changed = true;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (toDelete !== undefined) {
|
|
164
|
+
for (let i = 0; i < toDelete.length; i++) {
|
|
165
|
+
delete attributes[toDelete[i]];
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return changed;
|
|
169
|
+
};
|
|
170
|
+
}
|
package/build/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { createAttributeScrubber } from './attributes';
|
|
2
|
+
export { createSanitizeUrl, DEFAULT_QUERY_PARAMS_TO_SCRUB, defaultSanitizeUrl, REDACTED, } from './url';
|
|
3
|
+
export type { AttributeScrubber, AttributeScrubberOptions, SanitizeUrl, SanitizeUrlOptions, ScrubAttributes, ScrubbableValue, } from './types';
|
|
4
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,uBAAuB,EAAE,MAAM,cAAc,CAAC;AAEvD,OAAO,EACL,iBAAiB,EACjB,6BAA6B,EAC7B,kBAAkB,EAClB,QAAQ,GACT,MAAM,OAAO,CAAC;AAEf,YAAY,EACV,iBAAiB,EACjB,wBAAwB,EACxB,WAAW,EACX,kBAAkB,EAClB,eAAe,EACf,eAAe,GAChB,MAAM,SAAS,CAAC"}
|
package/build/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.REDACTED = exports.defaultSanitizeUrl = exports.DEFAULT_QUERY_PARAMS_TO_SCRUB = exports.createSanitizeUrl = exports.createAttributeScrubber = void 0;
|
|
4
|
+
var attributes_1 = require("./attributes");
|
|
5
|
+
Object.defineProperty(exports, "createAttributeScrubber", { enumerable: true, get: function () { return attributes_1.createAttributeScrubber; } });
|
|
6
|
+
var url_1 = require("./url");
|
|
7
|
+
Object.defineProperty(exports, "createSanitizeUrl", { enumerable: true, get: function () { return url_1.createSanitizeUrl; } });
|
|
8
|
+
Object.defineProperty(exports, "DEFAULT_QUERY_PARAMS_TO_SCRUB", { enumerable: true, get: function () { return url_1.DEFAULT_QUERY_PARAMS_TO_SCRUB; } });
|
|
9
|
+
Object.defineProperty(exports, "defaultSanitizeUrl", { enumerable: true, get: function () { return url_1.defaultSanitizeUrl; } });
|
|
10
|
+
Object.defineProperty(exports, "REDACTED", { enumerable: true, get: function () { return url_1.REDACTED; } });
|
package/build/types.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type SanitizeUrl = (url: string) => string;
|
|
2
|
+
export interface SanitizeUrlOptions {
|
|
3
|
+
additionalQueryParamsToScrub?: readonly string[];
|
|
4
|
+
queryParamsToScrub?: readonly string[];
|
|
5
|
+
redactCredentials?: boolean;
|
|
6
|
+
scrubFragment?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export type ScrubbableValue = string | number | boolean | null | undefined | Uint8Array | ScrubbableValue[] | {
|
|
9
|
+
[key: string]: ScrubbableValue;
|
|
10
|
+
};
|
|
11
|
+
export interface AttributeScrubber {
|
|
12
|
+
keys?: readonly string[];
|
|
13
|
+
keyPattern?: RegExp | readonly RegExp[];
|
|
14
|
+
shouldScrub?: (key: string) => boolean;
|
|
15
|
+
scrub?: (key: string, value: ScrubbableValue) => ScrubbableValue | undefined;
|
|
16
|
+
}
|
|
17
|
+
export interface AttributeScrubberOptions {
|
|
18
|
+
onError?: (message: string, error?: unknown) => void;
|
|
19
|
+
}
|
|
20
|
+
export type ScrubAttributes = (attributes: Record<string, unknown>) => boolean;
|
|
21
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAQA,MAAM,MAAM,WAAW,GAAG,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAC;AAKlD,MAAM,WAAW,kBAAkB;IAWjC,4BAA4B,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAUjD,kBAAkB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAKvC,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAW5B,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAsBD,MAAM,MAAM,eAAe,GACvB,MAAM,GACN,MAAM,GACN,OAAO,GACP,IAAI,GACJ,SAAS,GACT,UAAU,GACV,eAAe,EAAE,GACjB;IAAE,CAAC,GAAG,EAAE,MAAM,GAAG,eAAe,CAAA;CAAE,CAAC;AAyBvC,MAAM,WAAW,iBAAiB;IAQhC,IAAI,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IASzB,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,CAAC;IAgBxC,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;IAWvC,KAAK,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,eAAe,KAAK,eAAe,GAAG,SAAS,CAAC;CAC9E;AAKD,MAAM,WAAW,wBAAwB;IAQvC,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;CACtD;AAYD,MAAM,MAAM,eAAe,GAAG,CAAC,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,OAAO,CAAC"}
|
package/build/types.js
ADDED
package/build/url.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { SanitizeUrl, SanitizeUrlOptions } from './types';
|
|
2
|
+
export declare const REDACTED = "REDACTED";
|
|
3
|
+
export declare const DEFAULT_QUERY_PARAMS_TO_SCRUB: readonly string[];
|
|
4
|
+
export declare function createSanitizeUrl(options?: SanitizeUrlOptions): SanitizeUrl;
|
|
5
|
+
export declare function defaultSanitizeUrl(url: string): string;
|
|
6
|
+
//# sourceMappingURL=url.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"url.d.ts","sourceRoot":"","sources":["../src/url.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAC;AAY/D,eAAO,MAAM,QAAQ,aAAa,CAAC;AAMnC,eAAO,MAAM,6BAA6B,EAAE,SAAS,MAAM,EAoBzD,CAAC;AAgMH,wBAAgB,iBAAiB,CAC/B,OAAO,GAAE,kBAAuB,GAC/B,WAAW,CAsCb;AAgBD,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAEtD"}
|
package/build/url.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFAULT_QUERY_PARAMS_TO_SCRUB = exports.REDACTED = void 0;
|
|
4
|
+
exports.createSanitizeUrl = createSanitizeUrl;
|
|
5
|
+
exports.defaultSanitizeUrl = defaultSanitizeUrl;
|
|
6
|
+
exports.REDACTED = 'REDACTED';
|
|
7
|
+
exports.DEFAULT_QUERY_PARAMS_TO_SCRUB = Object.freeze([
|
|
8
|
+
'password',
|
|
9
|
+
'passwd',
|
|
10
|
+
'secret',
|
|
11
|
+
'api_key',
|
|
12
|
+
'apikey',
|
|
13
|
+
'auth',
|
|
14
|
+
'authorization',
|
|
15
|
+
'token',
|
|
16
|
+
'access_token',
|
|
17
|
+
'refresh_token',
|
|
18
|
+
'jwt',
|
|
19
|
+
'session',
|
|
20
|
+
'sessionid',
|
|
21
|
+
'key',
|
|
22
|
+
'private_key',
|
|
23
|
+
'client_secret',
|
|
24
|
+
'client_id',
|
|
25
|
+
'signature',
|
|
26
|
+
'hash',
|
|
27
|
+
]);
|
|
28
|
+
const SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*:/;
|
|
29
|
+
function isSlash(ch) {
|
|
30
|
+
return ch === '/' || ch === '\\';
|
|
31
|
+
}
|
|
32
|
+
function normalizeParamName(raw) {
|
|
33
|
+
let name = raw;
|
|
34
|
+
if (name.indexOf('+') !== -1) {
|
|
35
|
+
name = name.split('+').join(' ');
|
|
36
|
+
}
|
|
37
|
+
if (name.indexOf('%') !== -1) {
|
|
38
|
+
try {
|
|
39
|
+
name = decodeURIComponent(name);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return name.trim().toLowerCase();
|
|
45
|
+
}
|
|
46
|
+
function scrubParams(params, scrub) {
|
|
47
|
+
if (params === '' || params.indexOf('=') === -1) {
|
|
48
|
+
return params;
|
|
49
|
+
}
|
|
50
|
+
const pairs = params.split('&');
|
|
51
|
+
let changed = false;
|
|
52
|
+
for (let i = 0; i < pairs.length; i++) {
|
|
53
|
+
const pair = pairs[i];
|
|
54
|
+
const eq = pair.indexOf('=');
|
|
55
|
+
if (eq === -1) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const rawName = pair.slice(0, eq);
|
|
59
|
+
if (!scrub.has(normalizeParamName(rawName))) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const redacted = rawName + '=' + exports.REDACTED;
|
|
63
|
+
if (redacted !== pair) {
|
|
64
|
+
pairs[i] = redacted;
|
|
65
|
+
changed = true;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return changed ? pairs.join('&') : params;
|
|
69
|
+
}
|
|
70
|
+
function scrubFragmentParams(fragment, scrub) {
|
|
71
|
+
const q = fragment.indexOf('?');
|
|
72
|
+
if (q !== -1) {
|
|
73
|
+
return fragment.slice(0, q + 1) + scrubParams(fragment.slice(q + 1), scrub);
|
|
74
|
+
}
|
|
75
|
+
return scrubParams(fragment, scrub);
|
|
76
|
+
}
|
|
77
|
+
function redactCredentials(head) {
|
|
78
|
+
let start = -1;
|
|
79
|
+
if (head.length >= 2 && isSlash(head[0]) && isSlash(head[1])) {
|
|
80
|
+
start = 2;
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
const scheme = SCHEME.exec(head);
|
|
84
|
+
if (scheme !== null) {
|
|
85
|
+
let i = scheme[0].length;
|
|
86
|
+
let slashes = 0;
|
|
87
|
+
while (i < head.length && isSlash(head[i])) {
|
|
88
|
+
i++;
|
|
89
|
+
slashes++;
|
|
90
|
+
}
|
|
91
|
+
if (slashes >= 2) {
|
|
92
|
+
start = i;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (start === -1) {
|
|
97
|
+
return head;
|
|
98
|
+
}
|
|
99
|
+
let end = start;
|
|
100
|
+
while (end < head.length && !isSlash(head[end])) {
|
|
101
|
+
end++;
|
|
102
|
+
}
|
|
103
|
+
const authority = head.slice(start, end);
|
|
104
|
+
const at = authority.lastIndexOf('@');
|
|
105
|
+
if (at === -1) {
|
|
106
|
+
return head;
|
|
107
|
+
}
|
|
108
|
+
const userinfo = authority.slice(0, at);
|
|
109
|
+
const replacement = userinfo.indexOf(':') === -1 ? exports.REDACTED : exports.REDACTED + ':' + exports.REDACTED;
|
|
110
|
+
if (userinfo === replacement) {
|
|
111
|
+
return head;
|
|
112
|
+
}
|
|
113
|
+
return head.slice(0, start) + replacement + head.slice(start + at);
|
|
114
|
+
}
|
|
115
|
+
function sanitize(url, scrub, credentials, fragment) {
|
|
116
|
+
const hash = url.indexOf('#');
|
|
117
|
+
const beforeHash = hash === -1 ? url : url.slice(0, hash);
|
|
118
|
+
const fragmentText = hash === -1 ? '' : url.slice(hash + 1);
|
|
119
|
+
const question = beforeHash.indexOf('?');
|
|
120
|
+
const head = question === -1 ? beforeHash : beforeHash.slice(0, question);
|
|
121
|
+
const query = question === -1 ? '' : beforeHash.slice(question + 1);
|
|
122
|
+
let out = credentials ? redactCredentials(head) : head;
|
|
123
|
+
if (question !== -1) {
|
|
124
|
+
out += '?' + scrubParams(query, scrub);
|
|
125
|
+
}
|
|
126
|
+
if (hash !== -1) {
|
|
127
|
+
out +=
|
|
128
|
+
'#' +
|
|
129
|
+
(fragment ? scrubFragmentParams(fragmentText, scrub) : fragmentText);
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
function createSanitizeUrl(options = {}) {
|
|
134
|
+
var _a;
|
|
135
|
+
const scrub = new Set();
|
|
136
|
+
const base = (_a = options.queryParamsToScrub) !== null && _a !== void 0 ? _a : exports.DEFAULT_QUERY_PARAMS_TO_SCRUB;
|
|
137
|
+
for (let i = 0; i < base.length; i++) {
|
|
138
|
+
scrub.add(normalizeParamName(base[i]));
|
|
139
|
+
}
|
|
140
|
+
const extra = options.additionalQueryParamsToScrub;
|
|
141
|
+
if (extra) {
|
|
142
|
+
for (let i = 0; i < extra.length; i++) {
|
|
143
|
+
scrub.add(normalizeParamName(extra[i]));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
scrub.delete('');
|
|
147
|
+
const credentials = options.redactCredentials !== false;
|
|
148
|
+
const fragment = options.scrubFragment !== false;
|
|
149
|
+
return function sanitizeUrl(url) {
|
|
150
|
+
if (typeof url !== 'string' || url === '') {
|
|
151
|
+
return '';
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
return sanitize(url, scrub, credentials, fragment);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return exports.REDACTED;
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
const sanitizeWithDefaults = createSanitizeUrl();
|
|
162
|
+
function defaultSanitizeUrl(url) {
|
|
163
|
+
return sanitizeWithDefaults(url);
|
|
164
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"version.d.ts","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,OAAO,UAAU,CAAC"}
|
package/build/version.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sophonz/redaction",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "Sophonz Redaction",
|
|
5
|
+
"homepage": "https://sophonz.com",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/sophonz-labs/sophonz-js.git"
|
|
9
|
+
},
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
14
|
+
"files": [
|
|
15
|
+
"build/*",
|
|
16
|
+
"README-ko.md"
|
|
17
|
+
],
|
|
18
|
+
"main": "build/index.js",
|
|
19
|
+
"types": "build/index.d.ts",
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@sophonz/rollup-shared": "*"
|
|
22
|
+
},
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./build/index.d.ts",
|
|
26
|
+
"require": "./build/index.js",
|
|
27
|
+
"default": "./build/index.js"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|