Skip to content

Commit 1fb22b4

Browse files
authored
Merge pull request #448 from morluto/codex/custom-protocol-capture-365
feat(protocol): add custom TCP/UDP/IPC/XPC and auth-flow capture
2 parents 1a75f8d + 6fa6f9b commit 1fb22b4

2 files changed

Lines changed: 330 additions & 0 deletions

File tree

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { z } from "zod";
2+
3+
import { jsonValueSchema } from "./jsonValue.js";
4+
5+
/** Supported transport types for custom protocol capture. */
6+
export const transportTypeSchema = z.enum([
7+
"tcp",
8+
"udp",
9+
"ipc",
10+
"unix-socket",
11+
"named-pipe",
12+
"xpc",
13+
]);
14+
export type TransportType = z.infer<typeof transportTypeSchema>;
15+
16+
/** Direction of a protocol frame. */
17+
export const frameDirectionSchema = z.enum(["sent", "received", "intercepted"]);
18+
export type FrameDirection = z.infer<typeof frameDirectionSchema>;
19+
20+
/** A captured protocol frame. */
21+
export const protocolFrameSchema = z.strictObject({
22+
/** Monotonic sequence number. */
23+
sequence: z.number().int().nonnegative(),
24+
/** Timestamp in milliseconds. */
25+
at_ms: z.number().int().nonnegative(),
26+
/** Transport type. */
27+
transport: transportTypeSchema,
28+
/** Direction of the frame. */
29+
direction: frameDirectionSchema,
30+
/** Source process identity. */
31+
source_pid: z.number().int().nullable(),
32+
/** Destination process identity. */
33+
dest_pid: z.number().int().nullable(),
34+
/** Endpoint address (IP:port, socket path, pipe name). */
35+
source_endpoint: z.string().nullable(),
36+
/** Destination endpoint. */
37+
dest_endpoint: z.string().nullable(),
38+
/** Raw frame data as base64. */
39+
raw_data: z.string().nullable(),
40+
/** Decoded/parsed frame content if known. */
41+
decoded_content: jsonValueSchema.nullable(),
42+
/** Frame size in bytes. */
43+
size: z.number().int().nonnegative(),
44+
/** Whether the frame was truncated. */
45+
truncated: z.boolean().default(false),
46+
});
47+
export type ProtocolFrame = z.infer<typeof protocolFrameSchema>;
48+
49+
/** Authentication flow stage. */
50+
export const authStageSchema = z.enum([
51+
"init",
52+
"challenge",
53+
"response",
54+
"token_exchange",
55+
"renewal",
56+
"revocation",
57+
"failure",
58+
"success",
59+
]);
60+
export type AuthStage = z.infer<typeof authStageSchema>;
61+
62+
/** Token lifecycle event. */
63+
export const tokenLifecycleEventSchema = z.enum([
64+
"issued",
65+
"refreshed",
66+
"expired",
67+
"revoked",
68+
"renewed",
69+
]);
70+
export type TokenLifecycleEvent = z.infer<typeof tokenLifecycleEventSchema>;
71+
72+
/** A captured authentication flow event. */
73+
export const authFlowEventSchema = z.strictObject({
74+
/** Monotonic sequence number. */
75+
sequence: z.number().int().nonnegative(),
76+
/** Timestamp in milliseconds. */
77+
at_ms: z.number().int().nonnegative(),
78+
/** Authentication stage. */
79+
stage: authStageSchema,
80+
/** Protocol used. */
81+
protocol: z.string().min(1),
82+
/** Token type if applicable. */
83+
token_type: z.string().nullable(),
84+
/** Token lifecycle event. */
85+
token_lifecycle: tokenLifecycleEventSchema.nullable(),
86+
/** Correlation ID linking challenge-response pairs. */
87+
correlation_id: z.string().nullable(),
88+
/** Whether credentials were detected and redacted. */
89+
credentials_redacted: z.boolean().default(false),
90+
/** Whether the authentication succeeded. */
91+
succeeded: z.boolean().default(false),
92+
/** Error message if authentication failed. */
93+
error: z.string().nullable(),
94+
});
95+
export type AuthFlowEvent = z.infer<typeof authFlowEventSchema>;
96+
97+
/** A captured protocol session. */
98+
export const customProtocolCaptureSchema = z.strictObject({
99+
/** Transport type for this session. */
100+
transport: transportTypeSchema,
101+
/** Captured frames in order. */
102+
frames: z.array(protocolFrameSchema).min(0).max(100_000),
103+
/** Authentication flow events. */
104+
auth_events: z.array(authFlowEventSchema).default([]),
105+
/** Whether any frame was truncated. */
106+
has_truncated: z.boolean().default(false),
107+
/** Whether credentials were detected. */
108+
credentials_detected: z.boolean().default(false),
109+
});
110+
export type CustomProtocolCapture = z.infer<typeof customProtocolCaptureSchema>;
111+
112+
/** Correlate a frame with process identity. */
113+
export function correlateProcessIdentity(
114+
frame: ProtocolFrame,
115+
pid: number,
116+
): boolean {
117+
return frame.source_pid === pid || frame.dest_pid === pid;
118+
}
119+
120+
/** Check if a frame contains credential-like content. */
121+
export function looksLikeCredentialFrame(frame: ProtocolFrame): boolean {
122+
const patterns = [
123+
"password",
124+
"token",
125+
"secret",
126+
"api_key",
127+
"apikey",
128+
"authorization",
129+
"credential",
130+
];
131+
const content = JSON.stringify(frame.decoded_content ?? "").toLowerCase();
132+
return patterns.some((p) => content.includes(p));
133+
}
134+
135+
/** Filter frames by transport type. */
136+
export function framesByTransport(
137+
frames: readonly ProtocolFrame[],
138+
transport: TransportType,
139+
): ProtocolFrame[] {
140+
return frames.filter((f) => f.transport === transport);
141+
}
142+
143+
/** Get all authentication flow events for a specific protocol. */
144+
export function authEventsByProtocol(
145+
events: readonly AuthFlowEvent[],
146+
protocol: string,
147+
): AuthFlowEvent[] {
148+
return events.filter((e) => e.protocol === protocol);
149+
}
150+
151+
/** Get successful authentication events. */
152+
export function successfulAuthEvents(
153+
events: readonly AuthFlowEvent[],
154+
): AuthFlowEvent[] {
155+
return events.filter((e) => e.succeeded);
156+
}
157+
158+
/** Get failed authentication events. */
159+
export function failedAuthEvents(
160+
events: readonly AuthFlowEvent[],
161+
): AuthFlowEvent[] {
162+
return events.filter((e) => !e.succeeded);
163+
}
164+
165+
/** Compute the duration of an authentication flow in milliseconds. */
166+
export function authFlowDuration(
167+
events: readonly AuthFlowEvent[],
168+
): number | null {
169+
if (events.length === 0) return null;
170+
const first = events[0]!;
171+
const last = events[events.length - 1]!;
172+
return last.at_ms - first.at_ms;
173+
}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import {
4+
authEventsByProtocol,
5+
authFlowDuration,
6+
customProtocolCaptureSchema,
7+
correlateProcessIdentity,
8+
failedAuthEvents,
9+
framesByTransport,
10+
looksLikeCredentialFrame,
11+
successfulAuthEvents,
12+
type AuthFlowEvent,
13+
type FrameDirection,
14+
type AuthStage,
15+
type TokenLifecycleEvent,
16+
type CustomProtocolCapture,
17+
type ProtocolFrame,
18+
} from "../src/domain/customProtocolCapture.js";
19+
20+
const sampleFrames: ProtocolFrame[] = [
21+
{
22+
sequence: 0,
23+
at_ms: 100,
24+
transport: "tcp",
25+
direction: "sent",
26+
source_pid: 100,
27+
dest_pid: 200,
28+
source_endpoint: "127.0.0.1:8080",
29+
dest_endpoint: "127.0.0.1:9090",
30+
raw_data: "hello",
31+
decoded_content: "hello",
32+
size: 5,
33+
truncated: false,
34+
},
35+
{
36+
sequence: 1,
37+
at_ms: 200,
38+
transport: "udp",
39+
direction: "received",
40+
source_pid: 200,
41+
dest_pid: 100,
42+
source_endpoint: "127.0.0.1:9090",
43+
dest_endpoint: "127.0.0.1:8080",
44+
raw_data: "world",
45+
decoded_content: "world",
46+
size: 5,
47+
truncated: false,
48+
},
49+
];
50+
51+
const sampleAuthEvents: AuthFlowEvent[] = [
52+
{
53+
sequence: 0,
54+
at_ms: 100,
55+
stage: "init",
56+
protocol: "OAuth2",
57+
token_type: "Bearer",
58+
token_lifecycle: "issued",
59+
correlation_id: "abc-123",
60+
credentials_redacted: true,
61+
succeeded: false,
62+
error: null,
63+
},
64+
{
65+
sequence: 1,
66+
at_ms: 500,
67+
stage: "success",
68+
protocol: "OAuth2",
69+
token_type: "Bearer",
70+
token_lifecycle: "issued",
71+
correlation_id: "abc-123",
72+
credentials_redacted: true,
73+
succeeded: true,
74+
error: null,
75+
},
76+
];
77+
78+
describe("custom protocol capture", () => {
79+
it("validates a well-formed capture", () => {
80+
const capture = {
81+
transport: "tcp" as const,
82+
frames: sampleFrames,
83+
auth_events: sampleAuthEvents,
84+
has_truncated: false,
85+
credentials_detected: false,
86+
};
87+
const result = customProtocolCaptureSchema.safeParse(capture);
88+
expect(result.success).toBe(true);
89+
});
90+
91+
it("correlates process identity", () => {
92+
expect(correlateProcessIdentity(sampleFrames[0]!, 100)).toBe(true);
93+
expect(correlateProcessIdentity(sampleFrames[0]!, 200)).toBe(true);
94+
expect(correlateProcessIdentity(sampleFrames[0]!, 999)).toBe(false);
95+
});
96+
97+
it("filters frames by transport", () => {
98+
const tcpFrames = framesByTransport(sampleFrames, "tcp");
99+
expect(tcpFrames).toHaveLength(1);
100+
const udpFrames = framesByTransport(sampleFrames, "udp");
101+
expect(udpFrames).toHaveLength(1);
102+
});
103+
104+
it("detects credential-like frames", () => {
105+
const credFrame: ProtocolFrame = {
106+
...sampleFrames[0]!,
107+
decoded_content: { password: "secret" },
108+
};
109+
expect(looksLikeCredentialFrame(credFrame)).toBe(true);
110+
expect(looksLikeCredentialFrame(sampleFrames[0]!)).toBe(false);
111+
});
112+
113+
it("filters auth events by protocol", () => {
114+
const oauthEvents = authEventsByProtocol(sampleAuthEvents, "OAuth2");
115+
expect(oauthEvents).toHaveLength(2);
116+
const otherEvents = authEventsByProtocol(sampleAuthEvents, "SAML");
117+
expect(otherEvents).toHaveLength(0);
118+
});
119+
120+
it("gets successful auth events", () => {
121+
const success = successfulAuthEvents(sampleAuthEvents);
122+
expect(success).toHaveLength(1);
123+
expect(success[0]!.succeeded).toBe(true);
124+
});
125+
126+
it("gets failed auth events", () => {
127+
const failed = failedAuthEvents(sampleAuthEvents);
128+
expect(failed).toHaveLength(1);
129+
expect(failed[0]!.succeeded).toBe(false);
130+
});
131+
132+
it("computes auth flow duration", () => {
133+
const duration = authFlowDuration(sampleAuthEvents);
134+
expect(duration).toBe(400);
135+
});
136+
137+
it("returns null for empty auth events", () => {
138+
expect(authFlowDuration([])).toBeNull();
139+
});
140+
141+
it("uses all exported types", () => {
142+
const dir: FrameDirection = "sent";
143+
expect(dir).toBe("sent");
144+
const stage: AuthStage = "init";
145+
expect(stage).toBe("init");
146+
const event: TokenLifecycleEvent = "issued";
147+
expect(event).toBe("issued");
148+
const capture: CustomProtocolCapture = {
149+
transport: "tcp",
150+
frames: sampleFrames,
151+
auth_events: sampleAuthEvents,
152+
has_truncated: false,
153+
credentials_detected: false,
154+
};
155+
expect(capture.transport).toBe("tcp");
156+
});
157+
});

0 commit comments

Comments
 (0)