@bli-cockpit/telemetry-core 0.1.41 → 0.1.43
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/dist/collector-heartbeat.d.ts +4 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/memory-install-receipt.d.ts +4 -0
- package/dist/memory-install-receipt.js +24 -0
- package/dist/outbound-guard.d.ts +77 -0
- package/dist/outbound-guard.js +119 -0
- package/dist/secret-guards-content-rules.d.ts +60 -0
- package/dist/secret-guards-content-rules.js +122 -0
- package/dist/secret-guards-masking.d.ts +32 -0
- package/dist/secret-guards-masking.js +240 -0
- package/dist/secret-guards-metadata.d.ts +102 -0
- package/dist/secret-guards-metadata.js +192 -0
- package/dist/secret-guards-path-rules.d.ts +22 -0
- package/dist/secret-guards-path-rules.js +24 -0
- package/dist/secret-guards.d.ts +23 -107
- package/dist/secret-guards.js +24 -489
- package/package.json +1 -1
|
@@ -112,6 +112,10 @@ export declare const CollectorHeartbeatSchema: z.ZodObject<{
|
|
|
112
112
|
hook_chars_p50: z.ZodOptional<z.ZodNumber>;
|
|
113
113
|
hook_chars_p95: z.ZodOptional<z.ZodNumber>;
|
|
114
114
|
hook_chars_samples: z.ZodOptional<z.ZodNumber>;
|
|
115
|
+
hook_stop_runs_24h: z.ZodOptional<z.ZodNumber>;
|
|
116
|
+
hook_stop_failed_24h: z.ZodOptional<z.ZodNumber>;
|
|
117
|
+
hook_session_start_runs_24h: z.ZodOptional<z.ZodNumber>;
|
|
118
|
+
hook_session_start_failed_24h: z.ZodOptional<z.ZodNumber>;
|
|
115
119
|
hook_stats_reason: z.ZodOptional<z.ZodString>;
|
|
116
120
|
hook_performance: z.ZodOptional<z.ZodObject<{
|
|
117
121
|
schema_version: z.ZodLiteral<"memory-hook-performance.v1">;
|
package/dist/index.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export * from "./memory-hook-stats.js";
|
|
|
14
14
|
export * from "./memory-extract-context.js";
|
|
15
15
|
export * from "./memory-hook-performance.js";
|
|
16
16
|
export * from "./memory-install-receipt.js";
|
|
17
|
+
export * from "./outbound-guard.js";
|
|
17
18
|
export * from "./paths.js";
|
|
18
19
|
export * from "./privacy.js";
|
|
19
20
|
export * from "./risk-flags.js";
|
package/dist/index.js
CHANGED
|
@@ -14,6 +14,7 @@ export * from "./memory-hook-stats.js";
|
|
|
14
14
|
export * from "./memory-extract-context.js";
|
|
15
15
|
export * from "./memory-hook-performance.js";
|
|
16
16
|
export * from "./memory-install-receipt.js";
|
|
17
|
+
export * from "./outbound-guard.js";
|
|
17
18
|
export * from "./paths.js";
|
|
18
19
|
export * from "./privacy.js";
|
|
19
20
|
export * from "./risk-flags.js";
|
|
@@ -120,6 +120,10 @@ export declare const MemoryInstallReceiptSchema: z.ZodObject<{
|
|
|
120
120
|
hook_chars_p50: z.ZodOptional<z.ZodNumber>;
|
|
121
121
|
hook_chars_p95: z.ZodOptional<z.ZodNumber>;
|
|
122
122
|
hook_chars_samples: z.ZodOptional<z.ZodNumber>;
|
|
123
|
+
hook_stop_runs_24h: z.ZodOptional<z.ZodNumber>;
|
|
124
|
+
hook_stop_failed_24h: z.ZodOptional<z.ZodNumber>;
|
|
125
|
+
hook_session_start_runs_24h: z.ZodOptional<z.ZodNumber>;
|
|
126
|
+
hook_session_start_failed_24h: z.ZodOptional<z.ZodNumber>;
|
|
123
127
|
hook_stats_reason: z.ZodOptional<z.ZodString>;
|
|
124
128
|
hook_performance: z.ZodOptional<z.ZodObject<{
|
|
125
129
|
schema_version: z.ZodLiteral<"memory-hook-performance.v1">;
|
|
@@ -180,6 +180,30 @@ export const MemoryInstallReceiptSchema = z
|
|
|
180
180
|
hook_chars_p50: z.number().int().min(0).optional(),
|
|
181
181
|
hook_chars_p95: z.number().int().min(0).optional(),
|
|
182
182
|
hook_chars_samples: z.number().int().min(0).optional(),
|
|
183
|
+
/**
|
|
184
|
+
* THE OTHER TWO HOOKS (BLI-4057).
|
|
185
|
+
*
|
|
186
|
+
* Everything above counts the PROMPT hook only — the recall. Three hooks
|
|
187
|
+
* are installed on every machine and until this ticket the fleet heard
|
|
188
|
+
* from exactly one of them, so a SAVE hook that stopped firing (the one
|
|
189
|
+
* that writes memory at all) was invisible everywhere: the install
|
|
190
|
+
* receipt said `hooks ok`, the counts said 40 prompt recalls, and nothing
|
|
191
|
+
* anywhere said that not one memory had been written in ten days.
|
|
192
|
+
*
|
|
193
|
+
* `hook_stop_runs_24h` is the save hook; `hook_session_start_runs_24h` is
|
|
194
|
+
* the session-start recall. Each has its own failure count beside it,
|
|
195
|
+
* because a hook that runs and fails every time is a different fact from
|
|
196
|
+
* one that never ran — and the first is invisible in a run count alone.
|
|
197
|
+
*
|
|
198
|
+
* Optional like every count above, and ABSENT MEANS NOT REPORTED: a
|
|
199
|
+
* collector older than this ticket sends none, and a zero here would read
|
|
200
|
+
* as "the save hook ran zero times", which is exactly the alarm this
|
|
201
|
+
* ticket exists to raise. A reader may never substitute one for the other.
|
|
202
|
+
*/
|
|
203
|
+
hook_stop_runs_24h: z.number().int().min(0).optional(),
|
|
204
|
+
hook_stop_failed_24h: z.number().int().min(0).optional(),
|
|
205
|
+
hook_session_start_runs_24h: z.number().int().min(0).optional(),
|
|
206
|
+
hook_session_start_failed_24h: z.number().int().min(0).optional(),
|
|
183
207
|
/** Named when the counts are absent because the file could not be read. */
|
|
184
208
|
hook_stats_reason: ReasonLabelSchema.optional(),
|
|
185
209
|
/** Observed ordinary invocations, not legacy floors or a full host trace. */
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one seam every outbound write passes through (BLI-4086).
|
|
3
|
+
*
|
|
4
|
+
* The secret detector in this package was called at roughly fifty sites and
|
|
5
|
+
* every one of them was INGRESS — evidence upload, memory save, receipt
|
|
6
|
+
* parsing, notes intake, harvest extraction. Nothing ran on the way out. So
|
|
7
|
+
* the system could tell that a laptop had uploaded a credential and was blind
|
|
8
|
+
* when the assistant mailed one to somebody, and the assistant reaches every
|
|
9
|
+
* send path we own through MCP (`mail_send`, `msg_send`, `work_comment_issue`,
|
|
10
|
+
* `docs_update`, `notes_share`).
|
|
11
|
+
*
|
|
12
|
+
* **Why one function and not a guard per surface.** Five surfaces already
|
|
13
|
+
* disagreed about almost everything else — mail throws `MailDoorError`, msg
|
|
14
|
+
* throws `MsgDoorError`, Slack throws a bare `Error` — and a rule that has to
|
|
15
|
+
* be re-implemented five times is a rule that holds in four places within a
|
|
16
|
+
* quarter. The precedent is the mandatory-AI-SDK seam: make the single path
|
|
17
|
+
* the only path and let a census test fail when a sixth surface appears
|
|
18
|
+
* without it. `outbound-guard-census.test.ts` in the dashboard is that test.
|
|
19
|
+
*
|
|
20
|
+
* **It masks; it never refuses.** Consistent with the standing order that
|
|
21
|
+
* nothing is dropped — a refused send is a person's message deleted by a
|
|
22
|
+
* regex, and this detector's own header explains at length why coarse
|
|
23
|
+
* false positives are tolerable only because masking never costs the content.
|
|
24
|
+
* A masked send still sends, with `[REDACTED:<rule_id>]` where the value was.
|
|
25
|
+
*
|
|
26
|
+
* **It lives here, not in the dashboard**, because the worker's Linear mirror
|
|
27
|
+
* is an egress path too and cannot import from `apps/dashboard`. One choke
|
|
28
|
+
* point that stops at an app boundary is two choke points.
|
|
29
|
+
*/
|
|
30
|
+
/** The surfaces that can carry text out of Tower. Named so a log line can be grouped. */
|
|
31
|
+
export declare const OUTBOUND_SURFACES: readonly ["mail", "msg", "slack", "work", "docs", "linear_mirror", "cal"];
|
|
32
|
+
export type OutboundSurface = (typeof OUTBOUND_SURFACES)[number];
|
|
33
|
+
/** What was masked, for the caller that wants to say so in its own log line. */
|
|
34
|
+
export interface OutboundGuardReport {
|
|
35
|
+
/** True when at least one field changed. */
|
|
36
|
+
masked: boolean;
|
|
37
|
+
/** Field names that changed — names only, never values. */
|
|
38
|
+
maskedFields: string[];
|
|
39
|
+
/** Which rule caught what, and how much. The rule id IS the reason label. */
|
|
40
|
+
ruleCounts: {
|
|
41
|
+
rule_id: string;
|
|
42
|
+
match_count: number;
|
|
43
|
+
redacted_char_count: number;
|
|
44
|
+
}[];
|
|
45
|
+
/** Total matches across every field. */
|
|
46
|
+
matchCount: number;
|
|
47
|
+
}
|
|
48
|
+
export interface OutboundGuardResult<T> {
|
|
49
|
+
/** The same shape that came in, with secret-shaped spans replaced. */
|
|
50
|
+
fields: T;
|
|
51
|
+
report: OutboundGuardReport;
|
|
52
|
+
}
|
|
53
|
+
type GuardableFields = Record<string, string | null | undefined>;
|
|
54
|
+
/**
|
|
55
|
+
* Masks every secret-shaped span in `fields` and logs once if anything changed.
|
|
56
|
+
*
|
|
57
|
+
* `null` and `undefined` pass through untouched, so a caller can hand over an
|
|
58
|
+
* optional field (`html`, a patch's absent `title`) without a branch at every
|
|
59
|
+
* call site — the branch is what gets forgotten.
|
|
60
|
+
*
|
|
61
|
+
* The log line carries the surface, the field NAMES, the rule ids and the
|
|
62
|
+
* counts. It deliberately carries no fragment of the matched text: a guard
|
|
63
|
+
* that prints what it caught has moved the secret from a message into a log,
|
|
64
|
+
* which is the failure this whole family exists to prevent
|
|
65
|
+
* (`~/.claude/rules/security.md`, "never log PII").
|
|
66
|
+
*/
|
|
67
|
+
export declare function guardOutboundFields<T extends GuardableFields>(surface: OutboundSurface, fields: T): OutboundGuardResult<T>;
|
|
68
|
+
/**
|
|
69
|
+
* The single-field shorthand, for a writer that has exactly one body to guard.
|
|
70
|
+
*
|
|
71
|
+
* Prefer `guardOutboundFields` when a writer has two or more — it produces ONE
|
|
72
|
+
* log line for the send rather than one per field, and a reader counting
|
|
73
|
+
* masked sends should not have to know how many columns a surface happens to
|
|
74
|
+
* have.
|
|
75
|
+
*/
|
|
76
|
+
export declare function guardOutboundText(surface: OutboundSurface, field: string, text: string): string;
|
|
77
|
+
export {};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { redactSecretLikeContent } from "./secret-guards-masking.js";
|
|
2
|
+
/**
|
|
3
|
+
* The one seam every outbound write passes through (BLI-4086).
|
|
4
|
+
*
|
|
5
|
+
* The secret detector in this package was called at roughly fifty sites and
|
|
6
|
+
* every one of them was INGRESS — evidence upload, memory save, receipt
|
|
7
|
+
* parsing, notes intake, harvest extraction. Nothing ran on the way out. So
|
|
8
|
+
* the system could tell that a laptop had uploaded a credential and was blind
|
|
9
|
+
* when the assistant mailed one to somebody, and the assistant reaches every
|
|
10
|
+
* send path we own through MCP (`mail_send`, `msg_send`, `work_comment_issue`,
|
|
11
|
+
* `docs_update`, `notes_share`).
|
|
12
|
+
*
|
|
13
|
+
* **Why one function and not a guard per surface.** Five surfaces already
|
|
14
|
+
* disagreed about almost everything else — mail throws `MailDoorError`, msg
|
|
15
|
+
* throws `MsgDoorError`, Slack throws a bare `Error` — and a rule that has to
|
|
16
|
+
* be re-implemented five times is a rule that holds in four places within a
|
|
17
|
+
* quarter. The precedent is the mandatory-AI-SDK seam: make the single path
|
|
18
|
+
* the only path and let a census test fail when a sixth surface appears
|
|
19
|
+
* without it. `outbound-guard-census.test.ts` in the dashboard is that test.
|
|
20
|
+
*
|
|
21
|
+
* **It masks; it never refuses.** Consistent with the standing order that
|
|
22
|
+
* nothing is dropped — a refused send is a person's message deleted by a
|
|
23
|
+
* regex, and this detector's own header explains at length why coarse
|
|
24
|
+
* false positives are tolerable only because masking never costs the content.
|
|
25
|
+
* A masked send still sends, with `[REDACTED:<rule_id>]` where the value was.
|
|
26
|
+
*
|
|
27
|
+
* **It lives here, not in the dashboard**, because the worker's Linear mirror
|
|
28
|
+
* is an egress path too and cannot import from `apps/dashboard`. One choke
|
|
29
|
+
* point that stops at an app boundary is two choke points.
|
|
30
|
+
*/
|
|
31
|
+
/** The surfaces that can carry text out of Tower. Named so a log line can be grouped. */
|
|
32
|
+
export const OUTBOUND_SURFACES = [
|
|
33
|
+
"mail",
|
|
34
|
+
"msg",
|
|
35
|
+
"slack",
|
|
36
|
+
"work",
|
|
37
|
+
"docs",
|
|
38
|
+
"linear_mirror",
|
|
39
|
+
// BLI-4167. A calendar write is the widest egress on this list: Google
|
|
40
|
+
// EMAILS every attendee the moment the event lands, so the summary,
|
|
41
|
+
// description and location leave for addresses Tower does not control and
|
|
42
|
+
// no later edit unsends them. `lib/cal/write.ts` is the writer.
|
|
43
|
+
"cal",
|
|
44
|
+
];
|
|
45
|
+
/**
|
|
46
|
+
* Masks every secret-shaped span in `fields` and logs once if anything changed.
|
|
47
|
+
*
|
|
48
|
+
* `null` and `undefined` pass through untouched, so a caller can hand over an
|
|
49
|
+
* optional field (`html`, a patch's absent `title`) without a branch at every
|
|
50
|
+
* call site — the branch is what gets forgotten.
|
|
51
|
+
*
|
|
52
|
+
* The log line carries the surface, the field NAMES, the rule ids and the
|
|
53
|
+
* counts. It deliberately carries no fragment of the matched text: a guard
|
|
54
|
+
* that prints what it caught has moved the secret from a message into a log,
|
|
55
|
+
* which is the failure this whole family exists to prevent
|
|
56
|
+
* (`~/.claude/rules/security.md`, "never log PII").
|
|
57
|
+
*/
|
|
58
|
+
export function guardOutboundFields(surface, fields) {
|
|
59
|
+
const guarded = {};
|
|
60
|
+
const maskedFields = [];
|
|
61
|
+
const ruleTotals = new Map();
|
|
62
|
+
let matchCount = 0;
|
|
63
|
+
for (const [name, value] of Object.entries(fields)) {
|
|
64
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
65
|
+
guarded[name] = value;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
// `server_commit` is the honest source: the mask is applied by the server
|
|
69
|
+
// at the moment it commits the send. The enum has no `outbound` member and
|
|
70
|
+
// adding one would relabel live redaction receipts that
|
|
71
|
+
// `secret-guards-lock.test.ts` pins — a schema change for a log line is a
|
|
72
|
+
// bad trade, and no outbound mask is persisted as a receipt anyway.
|
|
73
|
+
const result = redactSecretLikeContent(value, {
|
|
74
|
+
appliedBy: "server_commit",
|
|
75
|
+
redactedFields: [name],
|
|
76
|
+
});
|
|
77
|
+
guarded[name] = result.text;
|
|
78
|
+
if (!result.redacted)
|
|
79
|
+
continue;
|
|
80
|
+
maskedFields.push(name);
|
|
81
|
+
for (const count of result.metadata?.rule_counts ?? []) {
|
|
82
|
+
const existing = ruleTotals.get(count.rule_id) ?? {
|
|
83
|
+
rule_id: count.rule_id,
|
|
84
|
+
match_count: 0,
|
|
85
|
+
redacted_char_count: 0,
|
|
86
|
+
};
|
|
87
|
+
existing.match_count += count.match_count;
|
|
88
|
+
existing.redacted_char_count += count.redacted_char_count;
|
|
89
|
+
ruleTotals.set(count.rule_id, existing);
|
|
90
|
+
matchCount += count.match_count;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const report = {
|
|
94
|
+
masked: maskedFields.length > 0,
|
|
95
|
+
maskedFields,
|
|
96
|
+
ruleCounts: [...ruleTotals.values()],
|
|
97
|
+
matchCount,
|
|
98
|
+
};
|
|
99
|
+
if (report.masked) {
|
|
100
|
+
console.warn("[outbound guard] masked", JSON.stringify({
|
|
101
|
+
surface,
|
|
102
|
+
masked_fields: report.maskedFields,
|
|
103
|
+
rule_counts: report.ruleCounts,
|
|
104
|
+
secret_like_match_count: report.matchCount,
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
return { fields: guarded, report };
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The single-field shorthand, for a writer that has exactly one body to guard.
|
|
111
|
+
*
|
|
112
|
+
* Prefer `guardOutboundFields` when a writer has two or more — it produces ONE
|
|
113
|
+
* log line for the send rather than one per field, and a reader counting
|
|
114
|
+
* masked sends should not have to know how many columns a surface happens to
|
|
115
|
+
* have.
|
|
116
|
+
*/
|
|
117
|
+
export function guardOutboundText(surface, field, text) {
|
|
118
|
+
return guardOutboundFields(surface, { [field]: text }).fields[field];
|
|
119
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which CONTENT looks like a secret — the detector, in the order it decides.
|
|
3
|
+
*
|
|
4
|
+
* Owned by `secret-guards.ts` (BLI-3986). Two kinds of rule live here and the
|
|
5
|
+
* difference between them is the whole D13 precision argument: a credential
|
|
6
|
+
* NAME is a leak only when it carries an assignment and an opaque value, while
|
|
7
|
+
* a self-identifying VALUE (a PEM block, a JWT, `ghp_…`, `AKIA…`) is a leak
|
|
8
|
+
* wherever it appears. `SECRET_LIKE_CONTENT_PATTERNS` is that order — the
|
|
9
|
+
* name-with-value rule first, then the value shapes as listed — and
|
|
10
|
+
* `secret-guards-masking.ts` reads the same list, so a rule added here without
|
|
11
|
+
* a masking rule beside it still redacts (coarsely) rather than costing a
|
|
12
|
+
* session.
|
|
13
|
+
*
|
|
14
|
+
* `SECRET_NAME_MATCH_SOURCE` and `SECRET_ASSIGNMENT_SOURCE` are exported for
|
|
15
|
+
* that sibling alone and are deliberately NOT re-exported by
|
|
16
|
+
* `secret-guards.ts`: the masking rule has to be built from the same fence as
|
|
17
|
+
* the detector, and a second spelling of it is the BLI-3116 hole again.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Credential names live INSIDE longer identifiers, so `\b` is the wrong fence
|
|
21
|
+
* (BLI-3116).
|
|
22
|
+
*
|
|
23
|
+
* Every name fragment above used to be wrapped in `\b(?:…)\b`. Underscore is a
|
|
24
|
+
* word character, so there is no word boundary between `AWS_` and `SECRET`:
|
|
25
|
+
* `AWS_SECRET_ACCESS_KEY=AKIA…` — the most common real spelling of the most
|
|
26
|
+
* common real leak — never matched and uploaded unmasked from every fleet
|
|
27
|
+
* machine, while the bare `SECRET_ACCESS_KEY=…` matched fine. The same hole hid
|
|
28
|
+
* `AZURE_OPENAI_API_KEY`, `VITE_SUPABASE_ANON_KEY`, `GITHUB_ACCESS_TOKEN`,
|
|
29
|
+
* `MY_APP_CLIENT_SECRET` and every other prefixed spelling: the bug was the
|
|
30
|
+
* fence, not the vocabulary, so the whole list is fenced differently now.
|
|
31
|
+
*
|
|
32
|
+
* `_`, `-` and `.` are identifier JOINERS here, not boundaries:
|
|
33
|
+
*
|
|
34
|
+
* - leading `(?<![A-Za-z0-9])` — the fragment may begin right after a joiner or
|
|
35
|
+
* at a real boundary, but a letter or digit immediately before it still
|
|
36
|
+
* blocks the match (`notapikey=…` stays out, as before).
|
|
37
|
+
* - trailing `[A-Za-z0-9_-]{0,40}` — a bounded identifier tail, so
|
|
38
|
+
* `OPENAI_API_KEY_2=…` and `AWS_SECRET_ACCESS_KEY_ID=…` are seen too.
|
|
39
|
+
*
|
|
40
|
+
* Prose is unaffected because the assignment requirement below is unchanged:
|
|
41
|
+
* "rotate your secret access key" has no `=`/`:` + opaque value and is not a
|
|
42
|
+
* leak. The guard's job is assignments, not vocabulary.
|
|
43
|
+
*/
|
|
44
|
+
export declare const SECRET_NAME_MATCH_SOURCE: string;
|
|
45
|
+
/**
|
|
46
|
+
* The assignment operator, with the closing quote of a JSON/YAML key allowed
|
|
47
|
+
* before it. Found while auditing the fence above: `{"aws_secret_access_key":
|
|
48
|
+
* "…"}` never matched either, because the name was followed by `"` and the
|
|
49
|
+
* pattern demanded `[:=]` immediately. Transcripts are JSONL, so this is the
|
|
50
|
+
* shape a leaked credential most often has on the way in. The sibling redactor
|
|
51
|
+
* in harvest-analysis (`study/prepare.ts`) already allowed it; this one did not.
|
|
52
|
+
*/
|
|
53
|
+
export declare const SECRET_ASSIGNMENT_SOURCE = "[\"']?\\s*[:=]\\s*[\"']?";
|
|
54
|
+
export declare const SECRET_LIKE_CONTENT_PATTERNS: RegExp[];
|
|
55
|
+
/**
|
|
56
|
+
* Does this text carry something the fleet must not upload as it stands? The
|
|
57
|
+
* one question the collector and the ingest door both ask, so a laptop and the
|
|
58
|
+
* server can never disagree about which bytes were secret-shaped.
|
|
59
|
+
*/
|
|
60
|
+
export declare function containsSecretLikeContent(value: string): boolean;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which CONTENT looks like a secret — the detector, in the order it decides.
|
|
3
|
+
*
|
|
4
|
+
* Owned by `secret-guards.ts` (BLI-3986). Two kinds of rule live here and the
|
|
5
|
+
* difference between them is the whole D13 precision argument: a credential
|
|
6
|
+
* NAME is a leak only when it carries an assignment and an opaque value, while
|
|
7
|
+
* a self-identifying VALUE (a PEM block, a JWT, `ghp_…`, `AKIA…`) is a leak
|
|
8
|
+
* wherever it appears. `SECRET_LIKE_CONTENT_PATTERNS` is that order — the
|
|
9
|
+
* name-with-value rule first, then the value shapes as listed — and
|
|
10
|
+
* `secret-guards-masking.ts` reads the same list, so a rule added here without
|
|
11
|
+
* a masking rule beside it still redacts (coarsely) rather than costing a
|
|
12
|
+
* session.
|
|
13
|
+
*
|
|
14
|
+
* `SECRET_NAME_MATCH_SOURCE` and `SECRET_ASSIGNMENT_SOURCE` are exported for
|
|
15
|
+
* that sibling alone and are deliberately NOT re-exported by
|
|
16
|
+
* `secret-guards.ts`: the masking rule has to be built from the same fence as
|
|
17
|
+
* the detector, and a second spelling of it is the BLI-3116 hole again.
|
|
18
|
+
*/
|
|
19
|
+
// Constructed from parts so the literal privileged key name never appears in
|
|
20
|
+
// this source file (the public-package pack scanner bans it as a content
|
|
21
|
+
// pattern, just as the original guard did).
|
|
22
|
+
const PRIVILEGED_SUPABASE_KEY_NAME = ["SUPABASE", "SERVICE", "ROLE", "KEY"].join("_");
|
|
23
|
+
const SUPABASE_SERVICE_ROLE_NAME = ["SUPABASE", "SERVICE", "ROLE"].join("_");
|
|
24
|
+
const OPENAI_KEY_NAME = ["OPENAI", "API", "KEY"].join("_");
|
|
25
|
+
const MEM0_KEY_NAME = ["MEM0", "API", "KEY"].join("_");
|
|
26
|
+
const SECRET_NAME_PATTERN_SOURCE = [
|
|
27
|
+
"(?:NEXT_PUBLIC_)?SUPABASE_ANON_KEY",
|
|
28
|
+
PRIVILEGED_SUPABASE_KEY_NAME,
|
|
29
|
+
SUPABASE_SERVICE_ROLE_NAME,
|
|
30
|
+
OPENAI_KEY_NAME,
|
|
31
|
+
MEM0_KEY_NAME,
|
|
32
|
+
"SERVICE[_\\s-]?ROLE(?:[_\\s-]?KEY)?",
|
|
33
|
+
"LANGFUSE_(?:PUBLIC|SECRET)_KEY",
|
|
34
|
+
"api[_-]?key",
|
|
35
|
+
"access[_-]?token",
|
|
36
|
+
"refresh[_-]?token",
|
|
37
|
+
"private[_-]?key",
|
|
38
|
+
"client[_-]?secret",
|
|
39
|
+
"secret[_-]?access[_-]?key",
|
|
40
|
+
].join("|");
|
|
41
|
+
/**
|
|
42
|
+
* Credential names live INSIDE longer identifiers, so `\b` is the wrong fence
|
|
43
|
+
* (BLI-3116).
|
|
44
|
+
*
|
|
45
|
+
* Every name fragment above used to be wrapped in `\b(?:…)\b`. Underscore is a
|
|
46
|
+
* word character, so there is no word boundary between `AWS_` and `SECRET`:
|
|
47
|
+
* `AWS_SECRET_ACCESS_KEY=AKIA…` — the most common real spelling of the most
|
|
48
|
+
* common real leak — never matched and uploaded unmasked from every fleet
|
|
49
|
+
* machine, while the bare `SECRET_ACCESS_KEY=…` matched fine. The same hole hid
|
|
50
|
+
* `AZURE_OPENAI_API_KEY`, `VITE_SUPABASE_ANON_KEY`, `GITHUB_ACCESS_TOKEN`,
|
|
51
|
+
* `MY_APP_CLIENT_SECRET` and every other prefixed spelling: the bug was the
|
|
52
|
+
* fence, not the vocabulary, so the whole list is fenced differently now.
|
|
53
|
+
*
|
|
54
|
+
* `_`, `-` and `.` are identifier JOINERS here, not boundaries:
|
|
55
|
+
*
|
|
56
|
+
* - leading `(?<![A-Za-z0-9])` — the fragment may begin right after a joiner or
|
|
57
|
+
* at a real boundary, but a letter or digit immediately before it still
|
|
58
|
+
* blocks the match (`notapikey=…` stays out, as before).
|
|
59
|
+
* - trailing `[A-Za-z0-9_-]{0,40}` — a bounded identifier tail, so
|
|
60
|
+
* `OPENAI_API_KEY_2=…` and `AWS_SECRET_ACCESS_KEY_ID=…` are seen too.
|
|
61
|
+
*
|
|
62
|
+
* Prose is unaffected because the assignment requirement below is unchanged:
|
|
63
|
+
* "rotate your secret access key" has no `=`/`:` + opaque value and is not a
|
|
64
|
+
* leak. The guard's job is assignments, not vocabulary.
|
|
65
|
+
*/
|
|
66
|
+
export const SECRET_NAME_MATCH_SOURCE = `(?<![A-Za-z0-9])(?:${SECRET_NAME_PATTERN_SOURCE})[A-Za-z0-9_-]{0,40}`;
|
|
67
|
+
/**
|
|
68
|
+
* The assignment operator, with the closing quote of a JSON/YAML key allowed
|
|
69
|
+
* before it. Found while auditing the fence above: `{"aws_secret_access_key":
|
|
70
|
+
* "…"}` never matched either, because the name was followed by `"` and the
|
|
71
|
+
* pattern demanded `[:=]` immediately. Transcripts are JSONL, so this is the
|
|
72
|
+
* shape a leaked credential most often has on the way in. The sibling redactor
|
|
73
|
+
* in harvest-analysis (`study/prepare.ts`) already allowed it; this one did not.
|
|
74
|
+
*/
|
|
75
|
+
export const SECRET_ASSIGNMENT_SOURCE = `["']?\\s*[:=]\\s*["']?`;
|
|
76
|
+
/**
|
|
77
|
+
* Credential names that are only a leak when assigned a value (D13). The value
|
|
78
|
+
* shape mirrors the long-standing generic pattern: an assignment operator
|
|
79
|
+
* followed by a 12+ char opaque value. A bare env var reference (name only) and
|
|
80
|
+
* `GRANT ... TO service_role` (no assignment) do not match; the same name with
|
|
81
|
+
* an assigned value does.
|
|
82
|
+
*/
|
|
83
|
+
const SECRET_NAME_WITH_VALUE_PATTERN = new RegExp(`${SECRET_NAME_MATCH_SOURCE}${SECRET_ASSIGNMENT_SOURCE}[A-Za-z0-9_./+=-]{12,}`, "i");
|
|
84
|
+
/**
|
|
85
|
+
* Self-identifying secret values — blocked regardless of context because the
|
|
86
|
+
* token shape itself is the credential, with no benign reading.
|
|
87
|
+
*
|
|
88
|
+
* `\b` is deliberately KEPT here, unlike the name fragments above (BLI-3116).
|
|
89
|
+
* These patterns are whole tokens, not fragments of a longer identifier: a
|
|
90
|
+
* `ghp_`/`sk-`/`AKIA…` run that continues into surrounding alphanumerics is not
|
|
91
|
+
* that provider's token, and unanchoring them would mask ordinary identifiers
|
|
92
|
+
* (`chart_m0_2024_revenue…`) for no safety gain. A real leak of any of these
|
|
93
|
+
* shapes is preceded by a quote, whitespace, `=`, `:` or `/` — all of which
|
|
94
|
+
* `\b` already admits.
|
|
95
|
+
*/
|
|
96
|
+
const SECRET_VALUE_PATTERNS = [
|
|
97
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----/,
|
|
98
|
+
/\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/,
|
|
99
|
+
/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/,
|
|
100
|
+
/\b(?:sk|pk)_(?:live|test)_[A-Za-z0-9]{16,}/,
|
|
101
|
+
/\bsk-[A-Za-z0-9_-]{16,}/,
|
|
102
|
+
/\bgh[pousr]_[A-Za-z0-9_]{16,}/,
|
|
103
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}/,
|
|
104
|
+
/\blin_api_[A-Za-z0-9]{16,}/,
|
|
105
|
+
/\bsb_secret_[A-Za-z0-9_]{16,}/,
|
|
106
|
+
/\bxox[baprs]-[A-Za-z0-9-]{16,}/,
|
|
107
|
+
/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/,
|
|
108
|
+
/\b(?:pk|sk)-lf-[A-Za-z0-9_-]{16,}\b/i,
|
|
109
|
+
/\bm0[-_][A-Za-z0-9_-]{16,}\b/i,
|
|
110
|
+
];
|
|
111
|
+
export const SECRET_LIKE_CONTENT_PATTERNS = [
|
|
112
|
+
SECRET_NAME_WITH_VALUE_PATTERN,
|
|
113
|
+
...SECRET_VALUE_PATTERNS,
|
|
114
|
+
];
|
|
115
|
+
/**
|
|
116
|
+
* Does this text carry something the fleet must not upload as it stands? The
|
|
117
|
+
* one question the collector and the ingest door both ask, so a laptop and the
|
|
118
|
+
* server can never disagree about which bytes were secret-shaped.
|
|
119
|
+
*/
|
|
120
|
+
export function containsSecretLikeContent(value) {
|
|
121
|
+
return SECRET_LIKE_CONTENT_PATTERNS.some((pattern) => pattern.test(value));
|
|
122
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { RawEvidenceRedactionMetadata, RawEvidenceRedactionSource } from "./secret-guards-metadata.js";
|
|
2
|
+
/**
|
|
3
|
+
* How a match is MASKED, and which rule gets to name it.
|
|
4
|
+
*
|
|
5
|
+
* Owned by `secret-guards.ts` (BLI-3986). `SECRET_REDACTION_RULES` is a table
|
|
6
|
+
* and its INDEX is a priority: the earliest rule that reaches a span wins, so
|
|
7
|
+
* the reason label a dashboard reads (`[REDACTED:openai_token]`,
|
|
8
|
+
* `rule_counts[].rule_id`) is decided by position in that list and by nothing
|
|
9
|
+
* else. Reordering it, or inserting a rule in the middle, relabels live
|
|
10
|
+
* redactions — `secret-guards-lock.test.ts` records the answers so that cannot
|
|
11
|
+
* happen by accident.
|
|
12
|
+
*
|
|
13
|
+
* Below the whole table sits the fallback (`unnamed_secret_shape`), ranked
|
|
14
|
+
* after every real rule: masking is best-effort and NEVER drops a file, so a
|
|
15
|
+
* detector hit no rule can pinpoint is masked coarsely rather than costing the
|
|
16
|
+
* session (the BLI-3116 note on the first rule explains what that cost was).
|
|
17
|
+
*/
|
|
18
|
+
export interface SecretRedactionResult {
|
|
19
|
+
redacted: boolean;
|
|
20
|
+
text: string;
|
|
21
|
+
metadata: RawEvidenceRedactionMetadata | null;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Mask every secret-shaped span and keep the rest of the text, because a
|
|
25
|
+
* session is never dropped for carrying one: this returns the redacted bytes
|
|
26
|
+
* plus the receipt that says which rules fired, where, and how much was
|
|
27
|
+
* replaced.
|
|
28
|
+
*/
|
|
29
|
+
export declare function redactSecretLikeContent(value: string, options?: {
|
|
30
|
+
appliedBy?: RawEvidenceRedactionSource;
|
|
31
|
+
redactedFields?: string[];
|
|
32
|
+
}): SecretRedactionResult;
|