Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ const engine = policy.PolicyEngine.init(bus, &registry);
var policy_id_buf: [16][]const u8 = undefined;
const result = engine.evaluate(.log, &my_log_accessor, &my_log_ctx, &policy_id_buf, .{
.scratch = arena.allocator(),
.io = io,
});

// Transforms whose required primitive (set/delete/move) is unwired on the
Expand Down
6 changes: 3 additions & 3 deletions src/bench/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ const LogState = struct {

pub fn run(self: *LogState, alloc: std.mem.Allocator) void {
_ = alloc;
_ = self.engine.evaluate(.log, &BenchLog.accessor, &self.ctx, &self.policy_id_buf, .{});
_ = self.engine.evaluate(.log, &BenchLog.accessor, &self.ctx, &self.policy_id_buf, .{ .io = std.Options.debug_io });
}
};

Expand Down Expand Up @@ -190,7 +190,7 @@ const MetricState = struct {

pub fn run(self: *MetricState, alloc: std.mem.Allocator) void {
_ = alloc;
_ = self.engine.evaluate(.metric, &BenchMetric.accessor, &self.ctx, &self.policy_id_buf, .{});
_ = self.engine.evaluate(.metric, &BenchMetric.accessor, &self.ctx, &self.policy_id_buf, .{ .io = std.Options.debug_io });
}
};

Expand Down Expand Up @@ -225,7 +225,7 @@ const TraceState = struct {

pub fn run(self: *TraceState, alloc: std.mem.Allocator) void {
_ = alloc;
_ = self.engine.evaluate(.trace, &BenchTrace.accessor, &self.ctx, &self.policy_id_buf, .{});
_ = self.engine.evaluate(.trace, &BenchTrace.accessor, &self.ctx, &self.policy_id_buf, .{ .io = std.Options.debug_io });
}
};

Expand Down
118 changes: 113 additions & 5 deletions src/policy/log_transform.zig
Original file line number Diff line number Diff line change
Expand Up @@ -202,9 +202,12 @@ pub fn applyRemove(
///
/// When `options.compiled` is null, replaces the entire field value with
/// `rule.replacement`. When non-null, runs the pre-compiled regex over the
/// `.string` value returned by `accessor.typed_value` and substitutes each
/// match using the pre-parsed replacement template. If the field is missing
/// or non-string, or the regex finds no match, the operation is a no-op.
/// value returned by `accessor.typed_value` and substitutes each match using
/// the pre-parsed replacement template. `.bytes` values (e.g. trace/span ids)
/// are hex-rendered first so regex redact can still target them, mirroring
/// how the engine hex-renders `.bytes` for string matchers. If the field is
/// missing, non-textual (bool/int/double), or the regex finds no match, the
/// operation is a no-op.
///
/// Returns true if the field was redacted.
pub fn applyRedact(
Expand All @@ -220,8 +223,12 @@ pub fn applyRedact(
const allocator = options.scratch orelse return false;
const io = options.io orelse return false;
const tv = accessor.typed_value(ctx, field_ref) orelse return false;
if (tv != .string) return false; // regex redact no-ops on non-string values
const value = tv.string;
var hex_buf: [types.max_hex_id_bytes * 2]u8 = undefined;
const value = switch (tv) {
.string => |s| s,
.bytes => |b| types.hexRenderId(b, &hex_buf),
.bool, .int, .double => return false, // regex redact no-ops on non-textual values
};

var out: std.ArrayList(u8) = .empty;
// No deinit: `out.items` is handed to the accessor.set by reference
Expand Down Expand Up @@ -647,6 +654,107 @@ test "applyRedact: regex on missing field is a no-op" {
try testing.expect(!result);
}

test "applyRedact: regex matches hex-rendered .bytes field (e.g. trace_id)" {
// Regression: identifier fields (LOG_FIELD_TRACE_ID/SPAN_ID) surface as
// `.bytes` via typed_value. Regex redact must hex-render them (like the
// engine does for string matchers) instead of no-oping.
const BytesCtx = struct {
const Self = @This();

stored: ?[]const u8 = null,

fn valueFn(ctx: *const anyopaque, field: FieldRef) ?types.TypedValue {
_ = field;
const self: *const Self = @ptrCast(@alignCast(ctx));
return .{ .bytes = self.stored orelse return null };
}

fn setFn(ctx: *anyopaque, field: FieldRef, value: []const u8) void {
_ = field;
const self: *Self = @ptrCast(@alignCast(ctx));
self.stored = value;
}

fn deleteFn(_: *anyopaque, _: FieldRef) bool {
return false;
}
fn moveFn(_: *anyopaque, _: FieldRef, _: []const u8) void {}
const accessor: LogAccessor = .{
.typed_value = valueFn,
.set = setFn,
.delete = deleteFn,
.move = moveFn,
};
};

const allocator = testing.allocator;
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();

// Raw bytes for trace_id 0xdeadbeef...
var ctx: BytesCtx = .{ .stored = &.{ 0xde, 0xad, 0xbe, 0xef } };

var rule: LogRedact = .{
.field = .{ .log_field = .LOG_FIELD_TRACE_ID },
.replacement = "$1",
.regex = "^(dead)beef$",
};

var compiled = try redact_mod.Compiled.init(allocator, rule.regex.?, rule.replacement);
defer compiled.deinit();

const result = applyRedact(&BytesCtx.accessor, &rule, @ptrCast(&ctx), .{
.compiled = &compiled,
.scratch = arena.allocator(),
.io = std.Options.debug_io,
});
try testing.expect(result);
try testing.expectEqualStrings("dead", ctx.stored.?);
}

test "applyRedact: regex no-ops on non-textual (bool/int/double) typed value" {
const NumCtx = struct {
const Self = @This();
fn valueFn(ctx: *const anyopaque, field: FieldRef) ?types.TypedValue {
_ = ctx;
_ = field;
return .{ .int = 42 };
}
fn setFn(_: *anyopaque, _: FieldRef, _: []const u8) void {}
fn deleteFn(_: *anyopaque, _: FieldRef) bool {
return false;
}
fn moveFn(_: *anyopaque, _: FieldRef, _: []const u8) void {}
const accessor: LogAccessor = .{
.typed_value = valueFn,
.set = setFn,
.delete = deleteFn,
.move = moveFn,
};
};

const allocator = testing.allocator;
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
var ctx: NumCtx = .{};

var rule: LogRedact = .{
.field = .{ .log_attribute = testAttrPath("count") },
.replacement = "X",
.regex = "\\d+",
};

var compiled = try redact_mod.Compiled.init(allocator, rule.regex.?, rule.replacement);
defer compiled.deinit();

const result = applyRedact(&NumCtx.accessor, &rule, @ptrCast(&ctx), .{
.compiled = &compiled,
.scratch = arena.allocator(),
.io = std.Options.debug_io,
});
try testing.expect(!result);
}

test "applyRename: renames existing field" {
const allocator = testing.allocator;
var ctx = TestContext.init(allocator);
Expand Down
63 changes: 59 additions & 4 deletions src/policy/matcher_index.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1066,10 +1066,13 @@ fn IndexBuilder(comptime T: TelemetryType) type {
return null;
},
// equals.string_value compiles to a Hyperscan pattern like
// `exact`; other equals variants are typed checks.
// `exact`, but as an escaped literal so it matches the raw
// string rather than being interpreted as regex syntax;
// other equals variants are typed checks.
.equals => |v| if (v.value != null and v.value.? == .string_value) {
const p = v.value.?.string_value;
if (!self.patternCompiles(p, .exact, matcher.case_insensitive)) {
const escaped = try escapeRegexLiteral(self.temp_allocator, p);
if (!self.patternCompiles(escaped, .exact, matcher.case_insensitive)) {
try self.recordError(signal ++ ": match[{d}]: invalid pattern \"{s}\"", policy_id, .{ idx, p });
return null;
}
Expand Down Expand Up @@ -1289,7 +1292,7 @@ fn IndexBuilder(comptime T: TelemetryType) type {
else => {},
}

const pattern, const pattern_match_type, const negate = switch (m) {
const raw_pattern, const pattern_match_type, const negate = switch (m) {
.regex => |r| .{ r, match_type.regex, matcher.negate },
.exact => |e| .{ e, match_type.exact, matcher.negate },
.equals => |v| .{ v.value.?.string_value, match_type.exact, matcher.negate },
Expand All @@ -1300,7 +1303,20 @@ fn IndexBuilder(comptime T: TelemetryType) type {
.gt, .gte, .lt, .lte => unreachable,
};

if (pattern.len == 0) {
// equals.string_value is typed literal equality (documented as
// such, unlike the regex-flavored `exact`), so escape regex
// metacharacters before it's compiled as a Hyperscan pattern.
// temp_allocator is fine here: the pattern text is only read
// while compiling the Hyperscan database in finish(), which
// runs before the builder's arena is torn down.
const pattern = if (m == .equals) try escapeRegexLiteral(self.temp_allocator, raw_pattern) else raw_pattern;

// Only regex/starts_with/ends_with/contains are trivially
// always-true when empty, so only they can be skipped as a
// no-op matcher; an empty `exact`/`equals` pattern is a
// meaningful "field is the empty string" check and must still
// be compiled (as `^$`).
if (pattern.len == 0 and pattern_match_type != .exact) {
self.bus.debug(MatcherEmptyRegex{ .matcher_idx = matcher_idx });
return;
}
Expand Down Expand Up @@ -2158,6 +2174,24 @@ fn compilePatterns(
return .{ .db = db, .meta = meta };
}

/// Escape regex metacharacters so `literal` can be embedded in a Hyperscan
/// pattern (via the `.exact` match type) and matched byte-for-byte, rather
/// than interpreted as regex syntax. Used for `equals.string_value`, which
/// is documented as typed string equality, not pattern matching.
fn escapeRegexLiteral(allocator: std.mem.Allocator, literal: []const u8) ![]u8 {
var out: std.ArrayList(u8) = .empty;
errdefer out.deinit(allocator);
try out.ensureTotalCapacity(allocator, literal.len);
for (literal) |c| {
switch (c) {
'\\', '^', '$', '.', '|', '?', '*', '+', '(', ')', '[', ']', '{', '}' => try out.append(allocator, '\\'),
else => {},
}
try out.append(allocator, c);
}
return out.toOwnedSlice(allocator);
}

fn formatPattern(buf: *[]u8, pattern: []const u8, pattern_match_type: match_type) []const u8 {
const anchor_start, const anchor_end = switch (pattern_match_type) {
.regex => return pattern,
Expand Down Expand Up @@ -3163,6 +3197,27 @@ test "formatPattern: contains returns pattern unchanged" {
try testing.expectEqualStrings("password", result);
}

test "escapeRegexLiteral: escapes regex metacharacters" {
const allocator = testing.allocator;
const escaped = try escapeRegexLiteral(allocator, "a.b(c)[d]$e^f");
defer allocator.free(escaped);
try testing.expectEqualStrings("a\\.b\\(c\\)\\[d\\]\\$e\\^f", escaped);
}

test "escapeRegexLiteral: plain string is unchanged" {
const allocator = testing.allocator;
const escaped = try escapeRegexLiteral(allocator, "checkout-api");
defer allocator.free(escaped);
try testing.expectEqualStrings("checkout-api", escaped);
}

test "escapeRegexLiteral: empty string stays empty" {
const allocator = testing.allocator;
const escaped = try escapeRegexLiteral(allocator, "");
defer allocator.free(escaped);
try testing.expectEqualStrings("", escaped);
}

test "Log matcher with starts_with" {
const allocator = testing.allocator;
var noop_bus: NoopEventBus = undefined;
Expand Down
57 changes: 55 additions & 2 deletions src/policy/parser.zig
Original file line number Diff line number Diff line change
Expand Up @@ -534,24 +534,29 @@ fn parseValue(allocator: std.mem.Allocator, json_val: std.json.Value) !Value {
.string => |s| return Value{ .value = .{ .string_value = try allocator.dupe(u8, s) } },
.object => |obj| {
if (obj.get("bool_value")) |v| {
if (v != .bool) return error.InvalidValue;
return Value{ .value = .{ .bool_value = v.bool } };
}
if (obj.get("int_value")) |v| {
if (v != .integer) return error.InvalidValue;
return Value{ .value = .{ .int_value = v.integer } };
}
if (obj.get("double_value")) |v| {
if (v != .float) return error.InvalidValue;
return Value{ .value = .{ .double_value = v.float } };
}
if (obj.get("hex_value")) |v| {
const hex_str = v.string;
const bytes = try hexDecode(allocator, hex_str);
if (v != .string) return error.InvalidValue;
const bytes = try hexDecode(allocator, v.string);
return Value{ .value = .{ .bytes_value = bytes } };
}
if (obj.get("bytes_value")) |v| {
if (v != .string) return error.InvalidValue;
const bytes = try allocator.dupe(u8, v.string);
return Value{ .value = .{ .bytes_value = bytes } };
}
if (obj.get("string_value")) |v| {
if (v != .string) return error.InvalidValue;
return Value{ .value = .{ .string_value = try allocator.dupe(u8, v.string) } };
}
return error.InvalidValue;
Expand Down Expand Up @@ -2693,6 +2698,54 @@ test "parseValue: string_value canonical (v1.6.0)" {
try std.testing.expectEqualStrings("checkout-api", v.value.?.string_value);
}

test "parseValue: string_value with non-string JSON type returns InvalidValue" {
const allocator = std.testing.allocator;
var obj = std.json.ObjectMap.empty;
defer obj.deinit(allocator);
try obj.put(allocator, "string_value", .{ .integer = 123 });
try std.testing.expectError(error.InvalidValue, parseValue(allocator, .{ .object = obj }));
}

test "parseValue: bool_value with non-bool JSON type returns InvalidValue" {
const allocator = std.testing.allocator;
var obj = std.json.ObjectMap.empty;
defer obj.deinit(allocator);
try obj.put(allocator, "bool_value", .{ .string = "true" });
try std.testing.expectError(error.InvalidValue, parseValue(allocator, .{ .object = obj }));
}

test "parseValue: int_value with non-integer JSON type returns InvalidValue" {
const allocator = std.testing.allocator;
var obj = std.json.ObjectMap.empty;
defer obj.deinit(allocator);
try obj.put(allocator, "int_value", .{ .string = "200" });
try std.testing.expectError(error.InvalidValue, parseValue(allocator, .{ .object = obj }));
}

test "parseValue: double_value with non-float JSON type returns InvalidValue" {
const allocator = std.testing.allocator;
var obj = std.json.ObjectMap.empty;
defer obj.deinit(allocator);
try obj.put(allocator, "double_value", .{ .integer = 5 });
try std.testing.expectError(error.InvalidValue, parseValue(allocator, .{ .object = obj }));
}

test "parseValue: hex_value with non-string JSON type returns InvalidValue" {
const allocator = std.testing.allocator;
var obj = std.json.ObjectMap.empty;
defer obj.deinit(allocator);
try obj.put(allocator, "hex_value", .{ .integer = 5 });
try std.testing.expectError(error.InvalidValue, parseValue(allocator, .{ .object = obj }));
}

test "parseValue: bytes_value with non-string JSON type returns InvalidValue" {
const allocator = std.testing.allocator;
var obj = std.json.ObjectMap.empty;
defer obj.deinit(allocator);
try obj.put(allocator, "bytes_value", .{ .bool = true });
try std.testing.expectError(error.InvalidValue, parseValue(allocator, .{ .object = obj }));
}

test "parseNumericValue: int" {
const v = try parseNumericValue(.{ .integer = 500 });
try std.testing.expect(v.value.? == .int_value);
Expand Down
Loading
Loading