@graphorin/observability 0.6.0 → 0.7.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 +42 -0
- package/README.md +29 -5
- package/dist/cost/cost-tracker.d.ts.map +1 -1
- package/dist/cost/cost-tracker.js +34 -0
- package/dist/cost/cost-tracker.js.map +1 -1
- package/dist/cost/delegate.d.ts +40 -0
- package/dist/cost/delegate.d.ts.map +1 -0
- package/dist/cost/delegate.js +31 -0
- package/dist/cost/delegate.js.map +1 -0
- package/dist/cost/index.d.ts +2 -1
- package/dist/cost/index.js +2 -1
- package/dist/cost/types.d.ts +39 -0
- package/dist/cost/types.d.ts.map +1 -1
- package/dist/exporters/otlp-http.d.ts.map +1 -1
- package/dist/exporters/otlp-http.js +4 -2
- package/dist/exporters/otlp-http.js.map +1 -1
- package/dist/exporters/types.d.ts +7 -0
- package/dist/exporters/types.d.ts.map +1 -1
- package/dist/exporters/types.js.map +1 -1
- package/dist/exporters/with-validation.d.ts.map +1 -1
- package/dist/exporters/with-validation.js +5 -4
- package/dist/exporters/with-validation.js.map +1 -1
- package/dist/gen-ai/emit.js +9 -1
- package/dist/gen-ai/emit.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -10
- package/dist/index.js.map +1 -1
- package/dist/package.js +6 -0
- package/dist/package.js.map +1 -0
- package/dist/redaction/imperative-patterns.d.ts +3 -3
- package/dist/redaction/imperative-patterns.d.ts.map +1 -1
- package/dist/redaction/imperative-patterns.js +7 -0
- package/dist/redaction/imperative-patterns.js.map +1 -1
- package/dist/redaction/patterns.d.ts.map +1 -1
- package/dist/redaction/patterns.js +2 -2
- package/dist/redaction/patterns.js.map +1 -1
- package/dist/tracer/sampling.d.ts +6 -1
- package/dist/tracer/sampling.d.ts.map +1 -1
- package/dist/tracer/sampling.js +7 -1
- package/dist/tracer/sampling.js.map +1 -1
- package/dist/tracer/span.d.ts.map +1 -1
- package/dist/tracer/span.js +12 -3
- package/dist/tracer/span.js.map +1 -1
- package/package.json +18 -35
- package/src/cost/cost-tracker.ts +315 -0
- package/src/cost/delegate.ts +79 -0
- package/src/cost/index.ts +23 -0
- package/src/cost/types.ts +151 -0
- package/src/eval/index.ts +16 -0
- package/src/eval/runner.ts +146 -0
- package/src/eval/types.ts +122 -0
- package/src/exporters/console.ts +75 -0
- package/src/exporters/index.ts +36 -0
- package/src/exporters/jsonl.ts +167 -0
- package/src/exporters/otlp-http.ts +178 -0
- package/src/exporters/types.ts +85 -0
- package/src/exporters/with-validation.ts +176 -0
- package/src/gen-ai/emit.ts +157 -0
- package/src/gen-ai/index.ts +26 -0
- package/src/gen-ai/operation-mapping.ts +89 -0
- package/src/gen-ai/system-derivation.ts +84 -0
- package/src/gen-ai/types.ts +143 -0
- package/src/index.ts +36 -0
- package/src/logger/index.ts +13 -0
- package/src/logger/logger.ts +178 -0
- package/src/openinference/index.ts +133 -0
- package/src/redaction/config.ts +50 -0
- package/src/redaction/errors.ts +53 -0
- package/src/redaction/imperative-patterns.ts +268 -0
- package/src/redaction/index.ts +42 -0
- package/src/redaction/patterns.ts +263 -0
- package/src/redaction/types.ts +116 -0
- package/src/redaction/validator.ts +302 -0
- package/src/replay/config.ts +58 -0
- package/src/replay/index.ts +17 -0
- package/src/replay/log.ts +89 -0
- package/src/replay/replay.ts +196 -0
- package/src/replay/types.ts +112 -0
- package/src/telemetry/index.ts +94 -0
- package/src/tracer/ids.ts +27 -0
- package/src/tracer/index.ts +22 -0
- package/src/tracer/sampling.ts +170 -0
- package/src/tracer/span-names.ts +52 -0
- package/src/tracer/span.ts +175 -0
- package/src/tracer/tracer.ts +309 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration shape consumed by `observability.validation.*`.
|
|
3
|
+
*
|
|
4
|
+
* The shape mirrors the canonical settings so consumer configuration
|
|
5
|
+
* files can use a single typed structure.
|
|
6
|
+
*
|
|
7
|
+
* @packageDocumentation
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Sensitivity } from '@graphorin/core';
|
|
11
|
+
|
|
12
|
+
import type { RedactionPattern } from './patterns.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @stable
|
|
16
|
+
*/
|
|
17
|
+
export interface ValidationConfig {
|
|
18
|
+
/** Lowest tier that may pass through the validator. */
|
|
19
|
+
readonly minTier?: Sensitivity;
|
|
20
|
+
/**
|
|
21
|
+
* When `true`, the validator throws on dropped values instead of
|
|
22
|
+
* silently dropping + counting. Use in tests; production should
|
|
23
|
+
* keep the default.
|
|
24
|
+
*
|
|
25
|
+
* @default false
|
|
26
|
+
*/
|
|
27
|
+
readonly failOnUnredactedSensitive?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Custom pattern catalogue. Defaults to the 14 default-on built-in
|
|
30
|
+
* patterns.
|
|
31
|
+
*/
|
|
32
|
+
readonly patterns?: ReadonlyArray<RedactionPattern>;
|
|
33
|
+
/** Per-name allow-list. */
|
|
34
|
+
readonly enabledPatterns?: ReadonlyArray<string>;
|
|
35
|
+
/** Per-name deny-list. */
|
|
36
|
+
readonly disabledPatterns?: ReadonlyArray<string>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Default validation configuration. Mirrors the runtime defaults used
|
|
41
|
+
* by `createTracer({ ... })` when `validation` is omitted.
|
|
42
|
+
*
|
|
43
|
+
* @stable
|
|
44
|
+
*/
|
|
45
|
+
export const DEFAULT_VALIDATION_CONFIG: Required<
|
|
46
|
+
Pick<ValidationConfig, 'minTier' | 'failOnUnredactedSensitive'>
|
|
47
|
+
> = Object.freeze({
|
|
48
|
+
minTier: 'public' as const,
|
|
49
|
+
failOnUnredactedSensitive: false,
|
|
50
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Errors thrown by the redaction layer.
|
|
3
|
+
*
|
|
4
|
+
* @packageDocumentation
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { RedactionViolation } from './types.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Thrown by the validator when `failOnUnredactedSensitive: true` and
|
|
11
|
+
* a value would otherwise be dropped or masked. The error never
|
|
12
|
+
* carries the secret value itself; only the sanitized
|
|
13
|
+
* {@link RedactionViolation} metadata.
|
|
14
|
+
*
|
|
15
|
+
* @stable
|
|
16
|
+
*/
|
|
17
|
+
export class RedactionValidationError extends Error {
|
|
18
|
+
/** @stable */
|
|
19
|
+
readonly kind = 'redaction-validation-failed' as const;
|
|
20
|
+
/** @stable */
|
|
21
|
+
readonly violation: RedactionViolation;
|
|
22
|
+
|
|
23
|
+
constructor(message: string, violation: RedactionViolation) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = 'RedactionValidationError';
|
|
26
|
+
this.violation = violation;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Thrown at startup when an exporter is registered without going
|
|
32
|
+
* through `withValidation(...)`. Enforces ADR-035: every exporter
|
|
33
|
+
* must validate before forwarding.
|
|
34
|
+
*
|
|
35
|
+
* @stable
|
|
36
|
+
*/
|
|
37
|
+
export class UnvalidatedExporterError extends Error {
|
|
38
|
+
/** @stable */
|
|
39
|
+
readonly kind = 'unvalidated-exporter' as const;
|
|
40
|
+
/** Exporter identifier (typically `exporter.constructor.name`). */
|
|
41
|
+
readonly exporterId: string;
|
|
42
|
+
|
|
43
|
+
constructor(exporterId: string) {
|
|
44
|
+
super(
|
|
45
|
+
`Exporter "${exporterId}" was registered without withValidation(). All ` +
|
|
46
|
+
'exporters must be wrapped - see RedactionValidator policy. Wrap with ' +
|
|
47
|
+
"`withValidation(exporter, opts)` or set `validation: 'off'` (NOT recommended) " +
|
|
48
|
+
'on the tracer to opt out explicitly (logs a startup WARN).',
|
|
49
|
+
);
|
|
50
|
+
this.name = 'UnvalidatedExporterError';
|
|
51
|
+
this.exporterId = exporterId;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Imperative-pattern catalogue for inbound prompt-injection defence.
|
|
3
|
+
*
|
|
4
|
+
* Sibling to `BUILT_IN_PATTERNS` (PII / secrets) - the two
|
|
5
|
+
* catalogues are disjoint by construction. The imperative catalogue
|
|
6
|
+
* is consumed by the inbound sanitization layer in `@graphorin/tools`
|
|
7
|
+
* to scan tool / MCP results before they reach the agent's message
|
|
8
|
+
* store; the PII / secrets catalogue is consumed by the outbound
|
|
9
|
+
* exporter validators to scan span attributes before they reach an
|
|
10
|
+
* exporter.
|
|
11
|
+
*
|
|
12
|
+
* The patterns target the canonical English "ignore previous
|
|
13
|
+
* instructions" / "system override" injection family - the concrete
|
|
14
|
+
* surface that an untrusted-skill or MCP server result might use to
|
|
15
|
+
* smuggle imperative content into the next provider call. The
|
|
16
|
+
* catalogue is intentionally conservative: every entry has a fixed-
|
|
17
|
+
* substring prefilter so the per-byte scan budget stays sub-millisecond
|
|
18
|
+
* on typical 16 KB tool results.
|
|
19
|
+
*
|
|
20
|
+
* @packageDocumentation
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Stable name of an imperative pattern. The catalogue is curated;
|
|
25
|
+
* user-supplied patterns can use any identifier they want and will be
|
|
26
|
+
* passed through the sanitization layer alongside the built-ins.
|
|
27
|
+
*
|
|
28
|
+
* @stable
|
|
29
|
+
*/
|
|
30
|
+
export type ImperativePatternName =
|
|
31
|
+
| 'ignore-previous-instructions'
|
|
32
|
+
| 'forget-instructions'
|
|
33
|
+
| 'override-instructions'
|
|
34
|
+
| 'system-prompt-leak'
|
|
35
|
+
| 'role-reassignment'
|
|
36
|
+
| 'developer-mode'
|
|
37
|
+
| 'jailbreak-marker'
|
|
38
|
+
| 'tool-call-injection'
|
|
39
|
+
| 'role-tag-injection'
|
|
40
|
+
| 'untrusted-content-delimiter-injection';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* One entry in the imperative-pattern catalogue. The shape mirrors
|
|
44
|
+
* `BUILT_IN_PATTERNS` so consumers can share scan / replace
|
|
45
|
+
* machinery, but the fields are typed as imperative-only so the two
|
|
46
|
+
* catalogues do not accidentally merge.
|
|
47
|
+
*
|
|
48
|
+
* @stable
|
|
49
|
+
*/
|
|
50
|
+
export interface ImperativePattern {
|
|
51
|
+
readonly name: ImperativePatternName | (string & {});
|
|
52
|
+
readonly description: string;
|
|
53
|
+
/**
|
|
54
|
+
* Cheap substring prefilter applied before the regex; if the body
|
|
55
|
+
* does not contain any of the prefilter substrings the regex is
|
|
56
|
+
* skipped entirely. The prefilter is case-insensitive.
|
|
57
|
+
*/
|
|
58
|
+
readonly prefilter: ReadonlyArray<string>;
|
|
59
|
+
/**
|
|
60
|
+
* Full regex applied when the prefilter matches. Always carries the
|
|
61
|
+
* `g` and `i` flags; the catalogue construction validates this.
|
|
62
|
+
*/
|
|
63
|
+
readonly regex: RegExp;
|
|
64
|
+
/** Replacement string applied by the `'detect-and-strip*'` policies. */
|
|
65
|
+
readonly mask: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const PATTERNS: readonly ImperativePattern[] = [
|
|
69
|
+
{
|
|
70
|
+
name: 'ignore-previous-instructions',
|
|
71
|
+
description:
|
|
72
|
+
'The canonical "ignore previous instructions" injection - the most common form of prompt injection seen across MCP results.',
|
|
73
|
+
prefilter: ['ignore', 'disregard', 'forget'],
|
|
74
|
+
regex:
|
|
75
|
+
/\b(?:ignore|disregard|forget)\s+(?:all\s+)?(?:the\s+)?(?:previous|prior|above|earlier|preceding|original)\s+(?:instructions?|prompts?|messages?|directives?|rules?)\b/gi,
|
|
76
|
+
mask: '[REDACTED:imperative-pattern]',
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: 'forget-instructions',
|
|
80
|
+
description: 'Imperative "forget what I told you" / memory-wipe family.',
|
|
81
|
+
prefilter: ['forget', 'erase', 'clear'],
|
|
82
|
+
regex:
|
|
83
|
+
/\b(?:forget|erase|clear|wipe)\s+(?:everything|all)\s+(?:above|before|previous|i\s+(?:told|said))\b/gi,
|
|
84
|
+
mask: '[REDACTED:imperative-pattern]',
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: 'override-instructions',
|
|
88
|
+
description: '"Override the system prompt" / "new instructions" injection family.',
|
|
89
|
+
prefilter: ['override', 'new instructions', 'updated instructions', 'replace'],
|
|
90
|
+
regex:
|
|
91
|
+
/\b(?:override|replace)\s+(?:the\s+)?(?:system\s+)?(?:prompt|instructions?|directives?)\b|\bnew\s+(?:instructions?|prompts?|directives?)\s*(?::|follow|are)\b|\bupdated\s+(?:instructions?|prompts?|directives?)\b/gi,
|
|
92
|
+
mask: '[REDACTED:imperative-pattern]',
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: 'system-prompt-leak',
|
|
96
|
+
description:
|
|
97
|
+
'Requests asking the model to reveal its system prompt / hidden instructions / configuration.',
|
|
98
|
+
prefilter: ['system prompt', 'system message', 'reveal', 'print', 'show'],
|
|
99
|
+
regex:
|
|
100
|
+
/\b(?:reveal|print|show|output|repeat|expose|disclose)\s+(?:your|the)\s+(?:system\s+(?:prompt|message|instructions?)|hidden\s+instructions?|initial\s+(?:prompt|instructions?)|configuration)\b/gi,
|
|
101
|
+
mask: '[REDACTED:imperative-pattern]',
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: 'role-reassignment',
|
|
105
|
+
description: '"You are now ..." / role-reassignment imperative family.',
|
|
106
|
+
prefilter: ['you are now', 'you are no longer', 'pretend', 'act as'],
|
|
107
|
+
regex:
|
|
108
|
+
/\byou\s+are\s+(?:now|no\s+longer)\s+(?:a\b|an\b|the\b)|\bpretend\s+(?:to\s+be|you\s+are)\b|\bact\s+as\s+(?:a|an|the|if\s+you)\b/gi,
|
|
109
|
+
mask: '[REDACTED:imperative-pattern]',
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: 'developer-mode',
|
|
113
|
+
description:
|
|
114
|
+
'Requests to enter "developer mode" / "DAN" / unrestricted modes - a long-running jailbreak family.',
|
|
115
|
+
prefilter: ['developer mode', 'admin mode', 'unrestricted'],
|
|
116
|
+
regex:
|
|
117
|
+
/\b(?:enter|enable|activate)\s+(?:developer|admin|debug|unrestricted|god|root)\s+mode\b/gi,
|
|
118
|
+
mask: '[REDACTED:imperative-pattern]',
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: 'jailbreak-marker',
|
|
122
|
+
description: 'Direct "jailbreak" markers in tool result content.',
|
|
123
|
+
prefilter: ['jailbreak', 'jailbroken'],
|
|
124
|
+
regex: /\bjailbroken?\s+(?:mode|response|model|assistant)\b/gi,
|
|
125
|
+
mask: '[REDACTED:imperative-pattern]',
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: 'tool-call-injection',
|
|
129
|
+
description:
|
|
130
|
+
'Tool-call markers that try to coerce the model into invoking a tool from the result content.',
|
|
131
|
+
prefilter: ['<tool_use', '<function_call', '<invoke'],
|
|
132
|
+
regex: /<(?:tool_use|function_call|invoke|tool_call)\b[^>]*>/gi,
|
|
133
|
+
mask: '[REDACTED:imperative-pattern]',
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: 'role-tag-injection',
|
|
137
|
+
description:
|
|
138
|
+
'Chat-role tags injected into tool results to spoof a system / assistant message in the conversation.',
|
|
139
|
+
prefilter: ['<|im_start|', '<|system|', '<|assistant|', '<|user|'],
|
|
140
|
+
regex: /<\|(?:im_start|system|assistant|user|im_end)\|>/gi,
|
|
141
|
+
mask: '[REDACTED:imperative-pattern]',
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
name: 'untrusted-content-delimiter-injection',
|
|
145
|
+
description:
|
|
146
|
+
'Fabricated `<<<untrusted_content>>>` envelope delimiters inside untrusted content - an attempt to prematurely close, or spoof a nested opening of, the inbound trust envelope. The regex is scoped STRICTLY to the envelope markers (never bare `<<<` / `>>>` runs) so legitimate Python doctest / REPL `>>>` and shell heredoc fragments are untouched.',
|
|
147
|
+
// The word alone is the prefilter: any marker form the regex can
|
|
148
|
+
// match necessarily contains it, including whitespace-padded
|
|
149
|
+
// variants like `<<< untrusted_content` that a tighter
|
|
150
|
+
// `<<<untrusted_content` prefilter would miss.
|
|
151
|
+
prefilter: ['untrusted_content'],
|
|
152
|
+
regex: /<<<\s*\/?\s*untrusted_content/gi,
|
|
153
|
+
mask: '[REDACTED:imperative-pattern]',
|
|
154
|
+
},
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The default-on imperative-pattern catalogue. Stable across patches;
|
|
159
|
+
* additions during the pre-1.0 window are minor-bumps because new
|
|
160
|
+
* patterns may produce additional `tool.inbound.sanitization.hit{...}`
|
|
161
|
+
* counter increments on existing deployments.
|
|
162
|
+
*
|
|
163
|
+
* @stable
|
|
164
|
+
*/
|
|
165
|
+
export const BUILT_IN_IMPERATIVE_PATTERNS: readonly ImperativePattern[] = PATTERNS;
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Combined Aho-Corasick-style prefilter set across every pattern.
|
|
169
|
+
* Lower-cased substrings; consumers test the body once with the
|
|
170
|
+
* combined filter before iterating regexes.
|
|
171
|
+
*
|
|
172
|
+
* @stable
|
|
173
|
+
*/
|
|
174
|
+
export const IMPERATIVE_PREFILTER_SUBSTRINGS: ReadonlyArray<string> = Object.freeze([
|
|
175
|
+
...new Set(BUILT_IN_IMPERATIVE_PATTERNS.flatMap((p) => p.prefilter.map((s) => s.toLowerCase()))),
|
|
176
|
+
]);
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Compiled scan helper. Returns the list of pattern names that fired
|
|
180
|
+
* AND the number of bytes the strip would remove if applied. Bounded
|
|
181
|
+
* by the budget hint - when exceeded, returns `null` to let the caller
|
|
182
|
+
* apply the best-effort `'detect-failed'` annotation.
|
|
183
|
+
*
|
|
184
|
+
* @stable
|
|
185
|
+
*/
|
|
186
|
+
export interface ScanResult {
|
|
187
|
+
readonly hits: ReadonlyArray<{ readonly pattern: string; readonly matchCount: number }>;
|
|
188
|
+
readonly bytesMatched: number;
|
|
189
|
+
readonly scanDurationUs: number;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Run the imperative-pattern scan against `body`. Patterns are
|
|
194
|
+
* iterated in catalogue order; the prefilter shortcut returns early
|
|
195
|
+
* for bodies that do not contain any imperative-family substring.
|
|
196
|
+
*
|
|
197
|
+
* @stable
|
|
198
|
+
*/
|
|
199
|
+
export function scanImperativePatterns(
|
|
200
|
+
body: string,
|
|
201
|
+
patterns: ReadonlyArray<ImperativePattern> = BUILT_IN_IMPERATIVE_PATTERNS,
|
|
202
|
+
budgetMs = 5,
|
|
203
|
+
): ScanResult | null {
|
|
204
|
+
const start = performance.now();
|
|
205
|
+
if (body.length === 0) {
|
|
206
|
+
return { hits: [], bytesMatched: 0, scanDurationUs: 0 };
|
|
207
|
+
}
|
|
208
|
+
const lower = body.toLowerCase();
|
|
209
|
+
let prefilterHit = false;
|
|
210
|
+
for (const sub of IMPERATIVE_PREFILTER_SUBSTRINGS) {
|
|
211
|
+
if (lower.includes(sub)) {
|
|
212
|
+
prefilterHit = true;
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (!prefilterHit) {
|
|
217
|
+
return {
|
|
218
|
+
hits: [],
|
|
219
|
+
bytesMatched: 0,
|
|
220
|
+
scanDurationUs: Math.round((performance.now() - start) * 1000),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
const hits: { pattern: string; matchCount: number }[] = [];
|
|
224
|
+
let bytesMatched = 0;
|
|
225
|
+
for (const pattern of patterns) {
|
|
226
|
+
if (performance.now() - start > budgetMs) return null;
|
|
227
|
+
const localPrefilterMatch = pattern.prefilter.some((sub) => lower.includes(sub.toLowerCase()));
|
|
228
|
+
if (!localPrefilterMatch) continue;
|
|
229
|
+
let matchCount = 0;
|
|
230
|
+
pattern.regex.lastIndex = 0;
|
|
231
|
+
let match: RegExpExecArray | null = pattern.regex.exec(body);
|
|
232
|
+
while (match !== null) {
|
|
233
|
+
matchCount++;
|
|
234
|
+
bytesMatched += match[0].length;
|
|
235
|
+
if (pattern.regex.lastIndex === match.index) {
|
|
236
|
+
pattern.regex.lastIndex = match.index + 1;
|
|
237
|
+
}
|
|
238
|
+
match = pattern.regex.exec(body);
|
|
239
|
+
}
|
|
240
|
+
if (matchCount > 0) {
|
|
241
|
+
hits.push({ pattern: pattern.name as string, matchCount });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
hits,
|
|
246
|
+
bytesMatched,
|
|
247
|
+
scanDurationUs: Math.round((performance.now() - start) * 1000),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Apply `pattern.mask` to every match of every pattern in `body`. Used
|
|
253
|
+
* by the `'detect-and-strip*'` policies. The mask is calibrated to NOT
|
|
254
|
+
* match any imperative pattern itself, so post-strip bodies do not
|
|
255
|
+
* trigger another scan hit on round trips.
|
|
256
|
+
*
|
|
257
|
+
* @stable
|
|
258
|
+
*/
|
|
259
|
+
export function stripImperativePatterns(
|
|
260
|
+
body: string,
|
|
261
|
+
patterns: ReadonlyArray<ImperativePattern> = BUILT_IN_IMPERATIVE_PATTERNS,
|
|
262
|
+
): string {
|
|
263
|
+
let out = body;
|
|
264
|
+
for (const pattern of patterns) {
|
|
265
|
+
out = out.replace(pattern.regex, pattern.mask);
|
|
266
|
+
}
|
|
267
|
+
return out;
|
|
268
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sensitivity-aware redaction surface for `@graphorin/observability`.
|
|
3
|
+
*
|
|
4
|
+
* The validator is the building block for the mandatory `withValidation()`
|
|
5
|
+
* wrapper applied to every exporter. It defaults to **default-deny
|
|
6
|
+
* non-public** - values declared above the tier floor are dropped + counted
|
|
7
|
+
* - and runs every value through the catalogue of built-in PII / secret
|
|
8
|
+
* patterns.
|
|
9
|
+
*
|
|
10
|
+
* @packageDocumentation
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export { DEFAULT_VALIDATION_CONFIG, type ValidationConfig } from './config.js';
|
|
14
|
+
export { RedactionValidationError, UnvalidatedExporterError } from './errors.js';
|
|
15
|
+
export {
|
|
16
|
+
BUILT_IN_IMPERATIVE_PATTERNS,
|
|
17
|
+
IMPERATIVE_PREFILTER_SUBSTRINGS,
|
|
18
|
+
type ImperativePattern,
|
|
19
|
+
type ImperativePatternName,
|
|
20
|
+
type ScanResult as ImperativeScanResult,
|
|
21
|
+
scanImperativePatterns,
|
|
22
|
+
stripImperativePatterns,
|
|
23
|
+
} from './imperative-patterns.js';
|
|
24
|
+
export {
|
|
25
|
+
ALL_BUILT_IN_PATTERNS,
|
|
26
|
+
BUILT_IN_PATTERNS,
|
|
27
|
+
type BuiltInPatternName,
|
|
28
|
+
OPT_IN_PATTERNS,
|
|
29
|
+
type PatternCategory,
|
|
30
|
+
type RedactionPattern,
|
|
31
|
+
} from './patterns.js';
|
|
32
|
+
export type {
|
|
33
|
+
RedactionCounters,
|
|
34
|
+
RedactionInput,
|
|
35
|
+
RedactionOutput,
|
|
36
|
+
RedactionValidator,
|
|
37
|
+
RedactionValidatorInstance,
|
|
38
|
+
RedactionValidatorOptions,
|
|
39
|
+
RedactionViolation,
|
|
40
|
+
RedactionViolationCallback,
|
|
41
|
+
} from './types.js';
|
|
42
|
+
export { compareSensitivityTiers, createRedactionValidator } from './validator.js';
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in PII / secret detection patterns. The catalogue is intentionally
|
|
3
|
+
* conservative - every pattern has both positive and negative test
|
|
4
|
+
* fixtures and is documented so operators understand exactly what is
|
|
5
|
+
* matched.
|
|
6
|
+
*
|
|
7
|
+
* The catalogue is split into two groups:
|
|
8
|
+
*
|
|
9
|
+
* - **secret** - credentials, API tokens, JWTs, private keys. Matches
|
|
10
|
+
* are always dropped + counted, regardless of the configured tier
|
|
11
|
+
* floor.
|
|
12
|
+
* - **pii** - email / phone / IBAN / credit card / SSN / IP address.
|
|
13
|
+
* Subject to the configured tier floor + per-pattern enable / disable
|
|
14
|
+
* knobs.
|
|
15
|
+
*
|
|
16
|
+
* @packageDocumentation
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Stable pattern identifier. The catalogue is curated; user-supplied
|
|
21
|
+
* patterns can use any identifier they want and will be passed through
|
|
22
|
+
* the validator in addition to the built-ins.
|
|
23
|
+
*
|
|
24
|
+
* @stable
|
|
25
|
+
*/
|
|
26
|
+
export type BuiltInPatternName =
|
|
27
|
+
| 'graphorin-token'
|
|
28
|
+
| 'openai-key'
|
|
29
|
+
| 'anthropic-key'
|
|
30
|
+
| 'aws-access-key'
|
|
31
|
+
| 'gcp-service-account'
|
|
32
|
+
| 'github-pat'
|
|
33
|
+
| 'jwt'
|
|
34
|
+
| 'bearer-header'
|
|
35
|
+
| 'basic-auth'
|
|
36
|
+
| 'private-key-pem'
|
|
37
|
+
| 'email'
|
|
38
|
+
| 'creditcard'
|
|
39
|
+
| 'us-ssn'
|
|
40
|
+
| 'phone-e164'
|
|
41
|
+
| 'iban'
|
|
42
|
+
| 'ipv4'
|
|
43
|
+
| 'ipv6';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Pattern category - `secret` matches always force a drop; `pii`
|
|
47
|
+
* matches respect the configured `enabledPatterns` allow-list.
|
|
48
|
+
*
|
|
49
|
+
* @stable
|
|
50
|
+
*/
|
|
51
|
+
export type PatternCategory = 'secret' | 'pii';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* One entry in the redaction catalogue.
|
|
55
|
+
*
|
|
56
|
+
* @stable
|
|
57
|
+
*/
|
|
58
|
+
export interface RedactionPattern {
|
|
59
|
+
readonly name: string;
|
|
60
|
+
readonly category: PatternCategory;
|
|
61
|
+
readonly description: string;
|
|
62
|
+
readonly regex: RegExp;
|
|
63
|
+
/** Replacement string used when `mode === 'mask'`. */
|
|
64
|
+
readonly mask?: string;
|
|
65
|
+
/**
|
|
66
|
+
* Optional per-match predicate (RP-21). When present, a regex hit is only
|
|
67
|
+
* treated as a real match - and masked - when this returns `true` for the
|
|
68
|
+
* matched substring. Used by the `creditcard` pattern to require a valid
|
|
69
|
+
* Luhn checksum so look-alike digit runs (epoch-ms timestamps, order ids)
|
|
70
|
+
* are not corrupted.
|
|
71
|
+
*/
|
|
72
|
+
readonly verify?: (match: string) => boolean;
|
|
73
|
+
/**
|
|
74
|
+
* Optional opt-in flag. When `true` the pattern is **not** active by
|
|
75
|
+
* default; operators must add it to `enabledPatterns` explicitly. Used
|
|
76
|
+
* by the IPv4 / IPv6 patterns because raw IPs frequently appear in
|
|
77
|
+
* non-PII log lines (host headers, debug traces, …).
|
|
78
|
+
*/
|
|
79
|
+
readonly optIn?: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const PATTERNS: readonly RedactionPattern[] = [
|
|
83
|
+
{
|
|
84
|
+
name: 'graphorin-token',
|
|
85
|
+
category: 'secret',
|
|
86
|
+
// Matches the DEFAULT token prefix from @graphorin/security
|
|
87
|
+
// (DEFAULT_TOKEN_PREFIX = 'gph'); deployments that configure a
|
|
88
|
+
// custom prefix must register their own pattern. The env label is
|
|
89
|
+
// matched loosely because `acceptEnvironments` is operator-extensible.
|
|
90
|
+
description: 'Graphorin server token (`gph_<env>_v1_<entropy>_<crc32>`; default prefix).',
|
|
91
|
+
regex: /gph_[a-z0-9]{2,12}_v1_[A-Za-z0-9]{20,80}_[A-Za-z0-9]{6}/g,
|
|
92
|
+
mask: '[REDACTED graphorin-token]',
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: 'openai-key',
|
|
96
|
+
category: 'secret',
|
|
97
|
+
description: 'OpenAI API key (`sk-...`).',
|
|
98
|
+
regex: /\bsk-[A-Za-z0-9_-]{20,}\b/g,
|
|
99
|
+
mask: '[REDACTED openai-key]',
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: 'anthropic-key',
|
|
103
|
+
category: 'secret',
|
|
104
|
+
description: 'Anthropic API key (`sk-ant-...`).',
|
|
105
|
+
regex: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g,
|
|
106
|
+
mask: '[REDACTED anthropic-key]',
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: 'aws-access-key',
|
|
110
|
+
category: 'secret',
|
|
111
|
+
description: 'AWS access-key id (`AKIA...`).',
|
|
112
|
+
regex: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g,
|
|
113
|
+
mask: '[REDACTED aws-access-key]',
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: 'gcp-service-account',
|
|
117
|
+
category: 'secret',
|
|
118
|
+
description:
|
|
119
|
+
'GCP service-account email (`...@...iam.gserviceaccount.com`). Opt-in; the `email` pattern already redacts the address shape; this entry exists for operators wanting the explicit `gcp-service-account` counter label.',
|
|
120
|
+
regex: /[A-Za-z0-9._-]+@[A-Za-z0-9-]+\.iam\.gserviceaccount\.com\b/g,
|
|
121
|
+
mask: '[REDACTED gcp-service-account]',
|
|
122
|
+
optIn: true,
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: 'github-pat',
|
|
126
|
+
category: 'secret',
|
|
127
|
+
description: 'GitHub personal-access token / OAuth token / app token.',
|
|
128
|
+
regex: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,255}\b/g,
|
|
129
|
+
mask: '[REDACTED github-pat]',
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
name: 'jwt',
|
|
133
|
+
category: 'secret',
|
|
134
|
+
description: 'JSON Web Token (`eyJ...`).',
|
|
135
|
+
regex: /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g,
|
|
136
|
+
mask: '[REDACTED jwt]',
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: 'bearer-header',
|
|
140
|
+
category: 'secret',
|
|
141
|
+
description: '`Authorization: Bearer ...` header values.',
|
|
142
|
+
regex: /\bBearer\s+[A-Za-z0-9._-]{16,}\b/gi,
|
|
143
|
+
mask: 'Bearer [REDACTED]',
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: 'basic-auth',
|
|
147
|
+
category: 'secret',
|
|
148
|
+
description: '`Authorization: Basic ...` header values.',
|
|
149
|
+
regex: /\bBasic\s+[A-Za-z0-9+/=]{16,}\b/gi,
|
|
150
|
+
mask: 'Basic [REDACTED]',
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: 'private-key-pem',
|
|
154
|
+
category: 'secret',
|
|
155
|
+
description: 'PEM private-key block (`-----BEGIN ... PRIVATE KEY-----`).',
|
|
156
|
+
regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]+?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
157
|
+
mask: '[REDACTED private-key-pem]',
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
name: 'email',
|
|
161
|
+
category: 'pii',
|
|
162
|
+
description: 'RFC-5322-ish email address.',
|
|
163
|
+
regex: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
|
|
164
|
+
mask: '[REDACTED email]',
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
name: 'creditcard',
|
|
168
|
+
category: 'pii',
|
|
169
|
+
description: 'Credit card number (13-19 digits, optional spaces / dashes; Luhn-checked).',
|
|
170
|
+
regex: /\b(?:\d[\s-]*?){13,19}\b/g,
|
|
171
|
+
mask: '[REDACTED creditcard]',
|
|
172
|
+
// RP-21: require a valid Luhn checksum so a 13-19 digit run that is not a
|
|
173
|
+
// real PAN (millisecond epoch timestamps, order numbers, …) is left alone.
|
|
174
|
+
verify: isLuhnValid,
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
name: 'us-ssn',
|
|
178
|
+
category: 'pii',
|
|
179
|
+
description: 'US Social Security Number (`123-45-6789`).',
|
|
180
|
+
regex: /\b\d{3}-\d{2}-\d{4}\b/g,
|
|
181
|
+
mask: '[REDACTED us-ssn]',
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
name: 'phone-e164',
|
|
185
|
+
category: 'pii',
|
|
186
|
+
description: 'E.164 phone number (`+11234567890`).',
|
|
187
|
+
regex: /\+\d{7,15}\b/g,
|
|
188
|
+
mask: '[REDACTED phone-e164]',
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
name: 'iban',
|
|
192
|
+
category: 'pii',
|
|
193
|
+
description: 'IBAN (2-letter country + 2 check digits + up to 30 alphanumerics).',
|
|
194
|
+
regex: /\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b/g,
|
|
195
|
+
mask: '[REDACTED iban]',
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
name: 'ipv4',
|
|
199
|
+
category: 'pii',
|
|
200
|
+
description: 'IPv4 dotted-quad address. Opt-in (often appears legitimately in logs).',
|
|
201
|
+
regex: /\b(?:\d{1,3}\.){3}\d{1,3}\b/g,
|
|
202
|
+
mask: '[REDACTED ipv4]',
|
|
203
|
+
optIn: true,
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
name: 'ipv6',
|
|
207
|
+
category: 'pii',
|
|
208
|
+
description: 'IPv6 address (full / compressed). Opt-in.',
|
|
209
|
+
regex: /\b(?:[0-9a-fA-F]{1,4}:){2,7}[0-9a-fA-F]{1,4}\b/g,
|
|
210
|
+
mask: '[REDACTED ipv6]',
|
|
211
|
+
optIn: true,
|
|
212
|
+
},
|
|
213
|
+
];
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The 14 default-on built-in patterns (the IPv4 and IPv6 detectors are
|
|
217
|
+
* opt-in and live in {@link OPT_IN_PATTERNS}).
|
|
218
|
+
*
|
|
219
|
+
* @stable
|
|
220
|
+
*/
|
|
221
|
+
export const BUILT_IN_PATTERNS: readonly RedactionPattern[] = PATTERNS.filter(
|
|
222
|
+
(p) => p.optIn !== true,
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Patterns that are recognised by the validator but are NOT enabled by
|
|
227
|
+
* default. Use them via `patterns: [...BUILT_IN_PATTERNS, ...OPT_IN_PATTERNS]`.
|
|
228
|
+
*
|
|
229
|
+
* @stable
|
|
230
|
+
*/
|
|
231
|
+
export const OPT_IN_PATTERNS: readonly RedactionPattern[] = PATTERNS.filter(
|
|
232
|
+
(p) => p.optIn === true,
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Full registry - for tooling that wants to introspect every pattern
|
|
237
|
+
* the framework knows about (e.g. CLI `graphorin redaction list`).
|
|
238
|
+
*
|
|
239
|
+
* @stable
|
|
240
|
+
*/
|
|
241
|
+
export const ALL_BUILT_IN_PATTERNS: readonly RedactionPattern[] = PATTERNS;
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Luhn (mod-10) checksum validator used by the `creditcard` pattern (RP-21).
|
|
245
|
+
* Strips spaces / dashes, bounds the length to 13-19 digits, and verifies the
|
|
246
|
+
* checksum so a digit run that merely *looks* like a PAN is not redacted.
|
|
247
|
+
*/
|
|
248
|
+
function isLuhnValid(value: string): boolean {
|
|
249
|
+
const digits = value.replace(/\D/g, '');
|
|
250
|
+
if (digits.length < 13 || digits.length > 19) return false;
|
|
251
|
+
let sum = 0;
|
|
252
|
+
let double = false;
|
|
253
|
+
for (let i = digits.length - 1; i >= 0; i -= 1) {
|
|
254
|
+
let d = digits.charCodeAt(i) - 48; // '0'
|
|
255
|
+
if (double) {
|
|
256
|
+
d *= 2;
|
|
257
|
+
if (d > 9) d -= 9;
|
|
258
|
+
}
|
|
259
|
+
sum += d;
|
|
260
|
+
double = !double;
|
|
261
|
+
}
|
|
262
|
+
return sum % 10 === 0;
|
|
263
|
+
}
|