@sudobility/sider_lib 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/dist/composite.d.ts +16 -0
- package/dist/composite.js +59 -0
- package/dist/execute.d.ts +27 -0
- package/dist/execute.js +58 -0
- package/dist/gates.d.ts +12 -0
- package/dist/gates.js +106 -0
- package/dist/gates.test.d.ts +1 -0
- package/dist/gates.test.js +88 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +16 -0
- package/dist/recipe.d.ts +18 -0
- package/dist/recipe.js +115 -0
- package/dist/templating.d.ts +13 -0
- package/dist/templating.js +66 -0
- package/dist/tokenize.d.ts +27 -0
- package/dist/tokenize.js +199 -0
- package/dist/tokenize.test.d.ts +1 -0
- package/dist/tokenize.test.js +42 -0
- package/package.json +28 -0
- package/src/composite.ts +86 -0
- package/src/execute.ts +89 -0
- package/src/gates.test.ts +98 -0
- package/src/gates.ts +124 -0
- package/src/index.ts +17 -0
- package/src/recipe.ts +143 -0
- package/src/templating.ts +69 -0
- package/src/tokenize.test.ts +54 -0
- package/src/tokenize.ts +242 -0
package/src/recipe.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Recipe compiler: ToolSpec + args → CompiledRequest (secrets still placeholders).
|
|
2
|
+
//
|
|
3
|
+
// Secrets are compiled to an internal SENTINEL (a Private-Use-Area delimited
|
|
4
|
+
// token that cannot occur in real data), NOT to their values. The sentinel
|
|
5
|
+
// survives through the egress gates and is only replaced with a real value at
|
|
6
|
+
// detokenization (see execute.ts), after all three gates pass.
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
CompiledRequest,
|
|
10
|
+
InvocationRecipe,
|
|
11
|
+
ParamBinding,
|
|
12
|
+
ToolSpec,
|
|
13
|
+
} from "@sudobility/sider_types";
|
|
14
|
+
|
|
15
|
+
const S = ""; // sentinel delimiter (PUA)
|
|
16
|
+
|
|
17
|
+
export function secretSentinel(slotId: string): string {
|
|
18
|
+
return `${S}${slotId}${S}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class RecipeCompileError extends Error {}
|
|
22
|
+
|
|
23
|
+
export interface CompileContext {
|
|
24
|
+
/** Origin the recipe runs against (path templates are relative to it). */
|
|
25
|
+
siteOrigin: string;
|
|
26
|
+
/** User/LLM-supplied tool arguments. */
|
|
27
|
+
args?: Record<string, unknown>;
|
|
28
|
+
/** Prior tool results this run, keyed by ComposeStep.ref (for priorOutput bindings). */
|
|
29
|
+
priorOutputs?: Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function compileRecipe(tool: ToolSpec, ctx: CompileContext): CompiledRequest {
|
|
33
|
+
const recipe = tool.recipe;
|
|
34
|
+
const referenced = new Set<string>();
|
|
35
|
+
|
|
36
|
+
const resolveKey = (key: string): string => {
|
|
37
|
+
const b = recipe.bindings[key];
|
|
38
|
+
if (!b) throw new RecipeCompileError(`Unbound placeholder {${key}} in tool "${tool.name}"`);
|
|
39
|
+
return resolveBinding(key, b, ctx, referenced);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const path = fillTemplate(recipe.urlTemplate, resolveKey);
|
|
43
|
+
const url = joinOrigin(ctx.siteOrigin, path);
|
|
44
|
+
|
|
45
|
+
const headers: Record<string, string> = {};
|
|
46
|
+
for (const h of recipe.headers) headers[h.name] = fillTemplate(h.valueTemplate, resolveKey);
|
|
47
|
+
|
|
48
|
+
const body =
|
|
49
|
+
recipe.bodyTemplate !== undefined ? resolveDeep(recipe.bodyTemplate, resolveKey) : undefined;
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
method: recipe.method,
|
|
53
|
+
url,
|
|
54
|
+
headers,
|
|
55
|
+
body,
|
|
56
|
+
credentials: "include",
|
|
57
|
+
referencedSlotIds: [...referenced],
|
|
58
|
+
safetyClass: tool.safetyClass,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function resolveBinding(
|
|
63
|
+
key: string,
|
|
64
|
+
b: ParamBinding,
|
|
65
|
+
ctx: CompileContext,
|
|
66
|
+
referenced: Set<string>,
|
|
67
|
+
): string {
|
|
68
|
+
switch (b.kind) {
|
|
69
|
+
case "literal":
|
|
70
|
+
return b.value;
|
|
71
|
+
case "arg": {
|
|
72
|
+
const v = ctx.args?.[b.argName];
|
|
73
|
+
if (v === undefined) throw new RecipeCompileError(`Missing arg "${b.argName}" for {${key}}`);
|
|
74
|
+
return String(v);
|
|
75
|
+
}
|
|
76
|
+
case "secret":
|
|
77
|
+
referenced.add(b.slotId);
|
|
78
|
+
return secretSentinel(b.slotId);
|
|
79
|
+
case "priorOutput": {
|
|
80
|
+
const v = getByPath(ctx.priorOutputs?.[b.stepRef], b.jsonPath);
|
|
81
|
+
if (v === undefined) {
|
|
82
|
+
throw new RecipeCompileError(`priorOutput ${b.stepRef}.${b.jsonPath} unresolved for {${key}}`);
|
|
83
|
+
}
|
|
84
|
+
return String(v);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function fillTemplate(tmpl: string, resolve: (key: string) => string): string {
|
|
90
|
+
return tmpl.replace(/\{([^{}]+)\}/g, (_m, key: string) => resolve(key));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function resolveDeep(node: unknown, resolve: (key: string) => string): unknown {
|
|
94
|
+
if (typeof node === "string") return fillTemplate(node, resolve);
|
|
95
|
+
if (Array.isArray(node)) return node.map((n) => resolveDeep(n, resolve));
|
|
96
|
+
if (node && typeof node === "object") {
|
|
97
|
+
const out: Record<string, unknown> = {};
|
|
98
|
+
for (const [k, v] of Object.entries(node)) out[k] = resolveDeep(v, resolve);
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
return node;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function joinOrigin(origin: string, path: string): string {
|
|
105
|
+
if (/^https?:\/\//i.test(path)) return path; // absolute (still same-origin-gated)
|
|
106
|
+
return `${origin.replace(/\/$/, "")}/${path.replace(/^\//, "")}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Read a dotted/bracketed path from an object: "data.sections[0].id". */
|
|
110
|
+
export function getByPath(obj: unknown, path: string): unknown {
|
|
111
|
+
const parts = path
|
|
112
|
+
.replace(/\[(\d+)\]/g, ".$1")
|
|
113
|
+
.split(".")
|
|
114
|
+
.filter(Boolean);
|
|
115
|
+
let cur: unknown = obj;
|
|
116
|
+
for (const p of parts) {
|
|
117
|
+
if (cur == null || typeof cur !== "object") return undefined;
|
|
118
|
+
cur = (cur as Record<string, unknown>)[p];
|
|
119
|
+
}
|
|
120
|
+
return cur;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- Recipe canonicalization for corroboration hashing --------------------
|
|
124
|
+
|
|
125
|
+
/** Stable stringify (sorted keys) so two users' equivalent recipes hash equal. */
|
|
126
|
+
export function canonicalizeRecipe(recipe: InvocationRecipe): string {
|
|
127
|
+
return stableStringify(recipe);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function hashRecipe(recipe: InvocationRecipe): string {
|
|
131
|
+
const s = canonicalizeRecipe(recipe);
|
|
132
|
+
let h = 0;
|
|
133
|
+
for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
|
|
134
|
+
return (h >>> 0).toString(16);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function stableStringify(v: unknown): string {
|
|
138
|
+
if (v === null || typeof v !== "object") return JSON.stringify(v) ?? "null";
|
|
139
|
+
if (Array.isArray(v)) return `[${v.map(stableStringify).join(",")}]`;
|
|
140
|
+
const obj = v as Record<string, unknown>;
|
|
141
|
+
const keys = Object.keys(obj).sort();
|
|
142
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`;
|
|
143
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Deterministic clustering & path templating. Runs BEFORE any AI: groups raw
|
|
2
|
+
// observations into endpoint templates so the brain infers semantics once per
|
|
3
|
+
// endpoint, not once per request. (This is the logic distill.ts inlined.)
|
|
4
|
+
|
|
5
|
+
import type { Observation } from "@sudobility/sider_types";
|
|
6
|
+
|
|
7
|
+
const UUID_RE =
|
|
8
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
9
|
+
const LONGHEX_RE = /^[0-9a-f]{16,}$/i;
|
|
10
|
+
|
|
11
|
+
/** Replace id-like path segments with `{id}`: /sections/120/seats → /sections/{id}/seats */
|
|
12
|
+
export function templatePath(rawUrl: string): string {
|
|
13
|
+
let pathname = rawUrl;
|
|
14
|
+
try {
|
|
15
|
+
pathname = new URL(rawUrl, "http://sider.local").pathname;
|
|
16
|
+
} catch {
|
|
17
|
+
/* keep raw */
|
|
18
|
+
}
|
|
19
|
+
return pathname
|
|
20
|
+
.split("/")
|
|
21
|
+
.map((seg) => {
|
|
22
|
+
if (seg === "") return seg;
|
|
23
|
+
if (/^\d+$/.test(seg)) return "{id}";
|
|
24
|
+
if (UUID_RE.test(seg)) return "{id}";
|
|
25
|
+
if (LONGHEX_RE.test(seg)) return "{id}";
|
|
26
|
+
return seg;
|
|
27
|
+
})
|
|
28
|
+
.join("/");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* GraphQL keying: one URL serves many operations, so cluster on operation name,
|
|
33
|
+
* not path. Returns undefined for non-GraphQL requests.
|
|
34
|
+
*/
|
|
35
|
+
export function graphqlOperationOf(url: string, body: unknown): string | undefined {
|
|
36
|
+
if (body && typeof body === "object") {
|
|
37
|
+
const b = body as Record<string, unknown>;
|
|
38
|
+
if (typeof b["operationName"] === "string" && b["operationName"]) {
|
|
39
|
+
return b["operationName"];
|
|
40
|
+
}
|
|
41
|
+
if (typeof b["query"] === "string") {
|
|
42
|
+
const m = /\b(query|mutation|subscription)\s+([A-Za-z0-9_]+)/.exec(b["query"]);
|
|
43
|
+
if (m && m[2]) return m[2];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (/\/graphql\b/i.test(url)) return "anonymous";
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type Clusterable = Pick<Observation, "method" | "url" | "requestBody">;
|
|
51
|
+
|
|
52
|
+
/** Stable key identifying the endpoint template an observation belongs to. */
|
|
53
|
+
export function endpointKey(o: Clusterable): string {
|
|
54
|
+
const op = graphqlOperationOf(o.url, o.requestBody);
|
|
55
|
+
return `${o.method} ${templatePath(o.url)}${op ? ` #${op}` : ""}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function clusterObservations<T extends Clusterable>(
|
|
59
|
+
observations: T[],
|
|
60
|
+
): Map<string, T[]> {
|
|
61
|
+
const groups = new Map<string, T[]>();
|
|
62
|
+
for (const o of observations) {
|
|
63
|
+
const key = endpointKey(o);
|
|
64
|
+
const g = groups.get(key);
|
|
65
|
+
if (g) g.push(o);
|
|
66
|
+
else groups.set(key, [o]);
|
|
67
|
+
}
|
|
68
|
+
return groups;
|
|
69
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { test, expect } from "bun:test";
|
|
2
|
+
import { tokenizeObservation } from "./tokenize";
|
|
3
|
+
import type { RawObservation, KnownSecret } from "./tokenize";
|
|
4
|
+
|
|
5
|
+
function raw(overrides: Partial<RawObservation> = {}): RawObservation {
|
|
6
|
+
return {
|
|
7
|
+
method: "GET",
|
|
8
|
+
url: "https://resale.fifa.com/api/seats",
|
|
9
|
+
requestHeaders: {},
|
|
10
|
+
requestBody: undefined,
|
|
11
|
+
status: 200,
|
|
12
|
+
responseBody: undefined,
|
|
13
|
+
timingMs: 10,
|
|
14
|
+
context: { route: "/seats" },
|
|
15
|
+
...overrides,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
test("masks a credential-named request header", () => {
|
|
20
|
+
const { observation, slots } = tokenizeObservation(
|
|
21
|
+
raw({ requestHeaders: { authorization: "Bearer abc.def.ghi" } }),
|
|
22
|
+
[],
|
|
23
|
+
);
|
|
24
|
+
expect(JSON.stringify(observation.requestHeaders)).not.toContain("abc.def.ghi");
|
|
25
|
+
expect(slots.length).toBeGreaterThan(0);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("masks a known cookie/storage value wherever it appears in a body", () => {
|
|
29
|
+
const known: KnownSecret[] = [
|
|
30
|
+
{
|
|
31
|
+
value: "SUPERSECRETVALUE123",
|
|
32
|
+
slotId: "slot:resale.fifa.com:session",
|
|
33
|
+
kind: "session",
|
|
34
|
+
role: "active_inject",
|
|
35
|
+
resolverHint: { from: "cookie", name: "sid" },
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
const { observation } = tokenizeObservation(
|
|
39
|
+
raw({ responseBody: { echoed: "SUPERSECRETVALUE123", ok: true } }),
|
|
40
|
+
known,
|
|
41
|
+
);
|
|
42
|
+
expect(JSON.stringify(observation.responseBody)).not.toContain("SUPERSECRETVALUE123");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("masks a JWT-shaped string in a body", () => {
|
|
46
|
+
const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0.abc123signature";
|
|
47
|
+
const { observation } = tokenizeObservation(raw({ responseBody: { token: jwt } }), []);
|
|
48
|
+
expect(JSON.stringify(observation.responseBody)).not.toContain(jwt);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("leaves non-secret data untouched", () => {
|
|
52
|
+
const { observation } = tokenizeObservation(raw({ responseBody: { price: 400, section: "A1" } }), []);
|
|
53
|
+
expect(observation.responseBody).toEqual({ price: 400, section: "A1" });
|
|
54
|
+
});
|
package/src/tokenize.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// Tokenizer (browser-side, but PURE here — the caller supplies the secret
|
|
2
|
+
// values it discovered in the user's own cookie jar / storage). Replaces secrets
|
|
3
|
+
// in a raw observation with a readable placeholder "{{secret:<slotId>}}" and
|
|
4
|
+
// emits SecretSlot proposals — so nothing with a real secret leaves the browser.
|
|
5
|
+
//
|
|
6
|
+
// Coverage (defense in depth, because storage cross-reference alone misses
|
|
7
|
+
// secrets minted this session or carried in bodies / non-standard headers):
|
|
8
|
+
// 1. Known values gathered from the user's own cookies/storage (exact match).
|
|
9
|
+
// 2. Any request header whose NAME looks credential-bearing.
|
|
10
|
+
// 3. Body fields whose KEY looks credential-bearing (e.g. access_token).
|
|
11
|
+
// 4. JWT-shaped strings anywhere in a body.
|
|
12
|
+
// Bias is conservative-for-privacy: over-mask rather than leak.
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
HttpMethod,
|
|
16
|
+
InjectionLocation,
|
|
17
|
+
Observation,
|
|
18
|
+
RequestContext,
|
|
19
|
+
ResolverHint,
|
|
20
|
+
SecretKind,
|
|
21
|
+
SecretRole,
|
|
22
|
+
SecretSlot,
|
|
23
|
+
} from "@sudobility/sider_types";
|
|
24
|
+
|
|
25
|
+
export interface RawObservation {
|
|
26
|
+
method: HttpMethod;
|
|
27
|
+
url: string;
|
|
28
|
+
requestHeaders: Record<string, string>;
|
|
29
|
+
requestBody?: unknown;
|
|
30
|
+
status: number;
|
|
31
|
+
responseBody?: unknown;
|
|
32
|
+
timingMs: number;
|
|
33
|
+
context: RequestContext;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A secret value the browser found in THIS user's session (value stays local). */
|
|
37
|
+
export interface KnownSecret {
|
|
38
|
+
value: string;
|
|
39
|
+
slotId: string; // stable placeholder, e.g. "slot:example.com:auth_bearer"
|
|
40
|
+
kind: SecretKind;
|
|
41
|
+
role: SecretRole;
|
|
42
|
+
resolverHint: ResolverHint;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type SecretSlotProposal = Omit<SecretSlot, "siteId" | "createdAt" | "updatedAt">;
|
|
46
|
+
export type TokenizedObservation = Omit<Observation, "id" | "batchId" | "createdAt">;
|
|
47
|
+
|
|
48
|
+
export interface TokenizeResult {
|
|
49
|
+
observation: TokenizedObservation;
|
|
50
|
+
slots: SecretSlotProposal[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function samplePlaceholder(slotId: string): string {
|
|
54
|
+
return `{{secret:${slotId}}}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Header NAME looks credential-bearing (conservative-for-privacy: any "key",
|
|
58
|
+
// "token", "auth", "secret", "session", etc. in the name).
|
|
59
|
+
const SECRET_HEADER_RE = /authorization|cookie|token|key|secret|session|auth|xsrf|csrf|bearer/i;
|
|
60
|
+
// Body property KEY looks credential-bearing.
|
|
61
|
+
const SECRET_KEY_RE =
|
|
62
|
+
/(^|[_-])(access_token|refresh_token|id_token|token|api[-_]?key|apikey|client_secret|secret|password|passwd|session_?(id|token)|jwt|bearer|credential|auth_?token)($|[_-])/i;
|
|
63
|
+
// JWT-shaped string.
|
|
64
|
+
const JWT_RE = /eyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{2,}/g;
|
|
65
|
+
const MIN_MASK_LEN = 6;
|
|
66
|
+
|
|
67
|
+
export function tokenizeObservation(raw: RawObservation, known: KnownSecret[]): TokenizeResult {
|
|
68
|
+
const origin = originOf(raw.url);
|
|
69
|
+
const host = hostOf(origin);
|
|
70
|
+
const slots = new Map<string, SecretSlotProposal>();
|
|
71
|
+
|
|
72
|
+
let url = raw.url;
|
|
73
|
+
const headers: Record<string, string> = { ...raw.requestHeaders };
|
|
74
|
+
let requestBody = raw.requestBody;
|
|
75
|
+
let responseBody = raw.responseBody;
|
|
76
|
+
|
|
77
|
+
const ensure = (slotId: string, kind: SecretKind, role: SecretRole, loc: InjectionLocation, hint?: ResolverHint) => {
|
|
78
|
+
if (slots.has(slotId)) return;
|
|
79
|
+
slots.set(slotId, {
|
|
80
|
+
id: slotId,
|
|
81
|
+
kind,
|
|
82
|
+
role,
|
|
83
|
+
injectionLocation: loc,
|
|
84
|
+
allowedDestination: origin,
|
|
85
|
+
resolverHints: hint ? [hint] : [],
|
|
86
|
+
confidence: 0.6,
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// 1. Known values (exact substring match everywhere).
|
|
91
|
+
for (const s of known) {
|
|
92
|
+
if (!s.value) continue;
|
|
93
|
+
const loc = locate(raw, s.value) ?? { at: "header" as const, name: "authorization" };
|
|
94
|
+
const ph = samplePlaceholder(s.slotId);
|
|
95
|
+
url = url.split(s.value).join(ph);
|
|
96
|
+
for (const k of Object.keys(headers)) headers[k] = headers[k]!.split(s.value).join(ph);
|
|
97
|
+
requestBody = replaceInJson(requestBody, s.value, ph);
|
|
98
|
+
responseBody = replaceInJson(responseBody, s.value, ph);
|
|
99
|
+
ensure(s.slotId, s.kind, s.role, loc, s.resolverHint);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 2. Credential-bearing headers (by name), even when the value wasn't in storage.
|
|
103
|
+
for (const name of Object.keys(headers)) {
|
|
104
|
+
const val = headers[name];
|
|
105
|
+
if (typeof val !== "string" || !val || val.startsWith("{{secret:")) continue;
|
|
106
|
+
if (!SECRET_HEADER_RE.test(name)) continue;
|
|
107
|
+
const lname = name.toLowerCase();
|
|
108
|
+
const slotId = `slot:${host}:${normalize(name)}`;
|
|
109
|
+
headers[name] = samplePlaceholder(slotId);
|
|
110
|
+
ensure(
|
|
111
|
+
slotId,
|
|
112
|
+
guessKind(name),
|
|
113
|
+
lname === "cookie" ? "auto_cookie" : "active_inject",
|
|
114
|
+
lname === "cookie" ? { at: "cookie", name } : { at: "header", name },
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 3 + 4. Body fields by key + JWTs anywhere.
|
|
119
|
+
requestBody = maskBody(requestBody, host, origin, slots, ensure, "");
|
|
120
|
+
responseBody = maskBody(responseBody, host, origin, slots, ensure, "");
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
observation: {
|
|
124
|
+
method: raw.method,
|
|
125
|
+
url,
|
|
126
|
+
requestHeaders: headers,
|
|
127
|
+
requestBody,
|
|
128
|
+
status: raw.status,
|
|
129
|
+
responseBody,
|
|
130
|
+
timingMs: raw.timingMs,
|
|
131
|
+
context: raw.context,
|
|
132
|
+
},
|
|
133
|
+
slots: [...slots.values()],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
type Ensure = (
|
|
138
|
+
slotId: string,
|
|
139
|
+
kind: SecretKind,
|
|
140
|
+
role: SecretRole,
|
|
141
|
+
loc: InjectionLocation,
|
|
142
|
+
hint?: ResolverHint,
|
|
143
|
+
) => void;
|
|
144
|
+
|
|
145
|
+
function maskBody(node: unknown, host: string, origin: string, slots: Map<string, SecretSlotProposal>, ensure: Ensure, path: string, key?: string): unknown {
|
|
146
|
+
if (node === undefined || node === null) return node;
|
|
147
|
+
if (typeof node === "string") {
|
|
148
|
+
if (key && node.length >= MIN_MASK_LEN && SECRET_KEY_RE.test(key)) {
|
|
149
|
+
const slotId = `slot:${host}:${normalize(key)}`;
|
|
150
|
+
ensure(slotId, guessKind(key), "active_inject", { at: "body", jsonPath: path });
|
|
151
|
+
return samplePlaceholder(slotId);
|
|
152
|
+
}
|
|
153
|
+
if (JWT_RE.test(node)) {
|
|
154
|
+
JWT_RE.lastIndex = 0;
|
|
155
|
+
const slotId = `slot:${host}:jwt`;
|
|
156
|
+
ensure(slotId, "bearer", "active_inject", { at: "body", jsonPath: path });
|
|
157
|
+
return node.replace(JWT_RE, samplePlaceholder(slotId));
|
|
158
|
+
}
|
|
159
|
+
return node;
|
|
160
|
+
}
|
|
161
|
+
if (Array.isArray(node)) return node.map((n, i) => maskBody(n, host, origin, slots, ensure, `${path}[${i}]`, key));
|
|
162
|
+
if (typeof node === "object") {
|
|
163
|
+
const out: Record<string, unknown> = {};
|
|
164
|
+
for (const [k, v] of Object.entries(node)) out[k] = maskBody(v, host, origin, slots, ensure, path ? `${path}.${k}` : k, k);
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
return node;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// --- helpers ---------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
function guessKind(name: string): SecretKind {
|
|
173
|
+
const n = name.toLowerCase();
|
|
174
|
+
if (n.includes("csrf") || n.includes("xsrf")) return "csrf";
|
|
175
|
+
if (n.includes("cookie") || n.includes("session")) return "session";
|
|
176
|
+
if (n.includes("auth") || n.includes("bearer") || n.includes("token") || n.includes("jwt")) return "bearer";
|
|
177
|
+
if (n.includes("api") && n.includes("key")) return "api_key";
|
|
178
|
+
return "unknown";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function locate(raw: RawObservation, value: string): InjectionLocation | null {
|
|
182
|
+
for (const [name, v] of Object.entries(raw.requestHeaders)) {
|
|
183
|
+
if (typeof v === "string" && v.includes(value)) return { at: "header", name };
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
const u = new URL(raw.url, "http://sider.local");
|
|
187
|
+
for (const [param, v] of u.searchParams) if (v.includes(value)) return { at: "query", param };
|
|
188
|
+
} catch {
|
|
189
|
+
/* ignore */
|
|
190
|
+
}
|
|
191
|
+
const bodyPath = findInJson(raw.requestBody, value);
|
|
192
|
+
if (bodyPath) return { at: "body", jsonPath: bodyPath };
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function findInJson(node: unknown, value: string, prefix = ""): string | null {
|
|
197
|
+
if (typeof node === "string") return node.includes(value) ? prefix.replace(/^\./, "") : null;
|
|
198
|
+
if (Array.isArray(node)) {
|
|
199
|
+
for (let i = 0; i < node.length; i++) {
|
|
200
|
+
const p = findInJson(node[i], value, `${prefix}[${i}]`);
|
|
201
|
+
if (p) return p;
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
if (node && typeof node === "object") {
|
|
206
|
+
for (const [k, v] of Object.entries(node)) {
|
|
207
|
+
const p = findInJson(v, value, `${prefix}.${k}`);
|
|
208
|
+
if (p) return p;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function replaceInJson(node: unknown, value: string, ph: string): unknown {
|
|
215
|
+
if (node === undefined) return node;
|
|
216
|
+
if (typeof node === "string") return node.split(value).join(ph);
|
|
217
|
+
if (Array.isArray(node)) return node.map((n) => replaceInJson(n, value, ph));
|
|
218
|
+
if (node && typeof node === "object") {
|
|
219
|
+
const out: Record<string, unknown> = {};
|
|
220
|
+
for (const [k, v] of Object.entries(node)) out[k] = replaceInJson(v, value, ph);
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
return node;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function originOf(url: string): string {
|
|
227
|
+
try {
|
|
228
|
+
return new URL(url).origin;
|
|
229
|
+
} catch {
|
|
230
|
+
return url;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
function hostOf(origin: string): string {
|
|
234
|
+
try {
|
|
235
|
+
return new URL(origin).host;
|
|
236
|
+
} catch {
|
|
237
|
+
return origin;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
function normalize(s: string): string {
|
|
241
|
+
return s.toLowerCase().replace(/[^a-z0-9]+/g, "_");
|
|
242
|
+
}
|