milestone 13 discrepancies: redact credentials from urls in logs, metrics and cli output
This commit is contained in:
@@ -2,8 +2,11 @@
|
||||
//!
|
||||
//! `listClients` returns only `hand_edited = 1` rows. A client the server
|
||||
//! materialised from live traffic is runtime state, not configuration, and must
|
||||
//! not appear in an export. `countClients` counts **all** rows, because S5's
|
||||
//! "has this database ever been configured" predicate needs the true count.
|
||||
//! not appear in an export. `hand_edited` is the only marker of operator intent
|
||||
//! in this table, so it also decides what `import.isEmpty` counts: a database
|
||||
//! carrying nothing but materialised rows has never been configured, and a seed
|
||||
//! file must still be able to fill it. `countClients` counts **all** rows and is
|
||||
//! a test helper — it deliberately does not answer that question.
|
||||
//!
|
||||
//! The import path is list / insert / deleteAll / count, plus the two runtime
|
||||
//! calls `upsertSeen` and `pruneStale` that the Phase 7 client tracker owns.
|
||||
@@ -134,7 +137,8 @@ pub fn deleteAllClients(database: *db.Db) db.Error!void {
|
||||
return database.exec("DELETE FROM clients;");
|
||||
}
|
||||
|
||||
/// Counts every row, including the ones `listClients` filters out.
|
||||
/// Counts every row, including the materialised ones `listClients` filters out.
|
||||
/// Used by tests; `import.isEmpty` counts operator intent instead.
|
||||
pub fn countClients(database: *db.Db) db.Error!i64 {
|
||||
return database.queryInt("SELECT count(*) FROM clients");
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const safe_url = @import("../../safe_url.zig");
|
||||
|
||||
const log = std.log.scoped(.repositories);
|
||||
|
||||
/// Group name → `groups.id`, or blocklist source URL → `blocklist_sources.id`.
|
||||
@@ -17,6 +19,42 @@ pub const IdMap = std.StringHashMapUnmanaged(i64);
|
||||
|
||||
const no_ids: IdMap = .empty;
|
||||
|
||||
/// The line either lookup writes when the caller's map lacks a name, as a value
|
||||
/// rather than a format string at each call site.
|
||||
///
|
||||
/// Two reasons, in that order. A blocklist source url is operator-supplied and
|
||||
/// nothing on the way in stops it carrying a credential in its userinfo, its
|
||||
/// path or its query, so it reaches the log through `safe_url.redact` and
|
||||
/// through nothing else. And a `std.log` line is not readable from a unit test
|
||||
/// under the default test runner, which installs its own `std_options`; the
|
||||
/// tests below read this value instead of stderr.
|
||||
const MissingId = union(enum) {
|
||||
/// A group name, out of the configuration file or a `groups` row.
|
||||
group: []const u8,
|
||||
/// A blocklist source url, out of the configuration file or a
|
||||
/// `blocklist_sources` row.
|
||||
source: []const u8,
|
||||
|
||||
pub fn format(self: MissingId, w: *std.Io.Writer) std.Io.Writer.Error!void {
|
||||
switch (self) {
|
||||
// A group name holds no credential, so nothing is dropped from it.
|
||||
// It goes through `quoteText` for what that does to any
|
||||
// operator-supplied string: it delimits a name with a space in it,
|
||||
// escapes a `'` that would otherwise close the delimiter, and bounds
|
||||
// a name of any length. The quotes are the type's own, so this
|
||||
// format string adds none.
|
||||
.group => |name| try w.print("no group id for {f}", .{safe_url.quoteText(name)}),
|
||||
// Redaction costs this line the component that told two sources on
|
||||
// one host apart, and there is no row id to name the source by
|
||||
// instead: the id the other call sites print is the one this call
|
||||
// failed to find. The scheme, the host and the port are what is
|
||||
// left. The rule against writing a secret to a log does not bend for
|
||||
// a line that would read better with one.
|
||||
.source => |url| try w.print("no blocklist source id for {f}", .{safe_url.redact(url)}),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
pub const InsertContext = struct {
|
||||
/// Unix epoch seconds, from `std.Io.Clock.real.now(io).toSeconds()`.
|
||||
now: i64 = 0,
|
||||
@@ -29,14 +67,16 @@ pub const InsertContext = struct {
|
||||
/// `NotFound` is a member of `db.Error`, so it needs no wider error set.
|
||||
pub fn groupId(self: InsertContext, name: []const u8) error{NotFound}!i64 {
|
||||
return self.group_ids.get(name) orelse {
|
||||
log.warn("no group id for '{s}'", .{name});
|
||||
log.warn("{f}", .{MissingId{ .group = name }});
|
||||
return error.NotFound;
|
||||
};
|
||||
}
|
||||
|
||||
/// `error.NotFound` means what it means in `groupId`, for the map keyed by
|
||||
/// source url.
|
||||
pub fn sourceId(self: InsertContext, url: []const u8) error{NotFound}!i64 {
|
||||
return self.source_ids.get(url) orelse {
|
||||
log.warn("no blocklist source id for '{s}'", .{url});
|
||||
log.warn("{f}", .{MissingId{ .source = url }});
|
||||
return error.NotFound;
|
||||
};
|
||||
}
|
||||
@@ -44,6 +84,48 @@ pub const InsertContext = struct {
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
fn expectLine(expected: []const u8, missing: MissingId) !void {
|
||||
var buf: [8 * safe_url.max_len]u8 = undefined;
|
||||
try testing.expectEqualStrings(expected, try std.fmt.bufPrint(&buf, "{f}", .{missing}));
|
||||
}
|
||||
|
||||
test "a missing source id names where the url points and not what it carries" {
|
||||
// The three components a blocklist url carries a credential in, on the one
|
||||
// line that used to print all three: an api key in the query, userinfo, and
|
||||
// a token in a path segment.
|
||||
try expectLine(
|
||||
"no blocklist source id for https://lists.example",
|
||||
.{ .source = "https://lists.example/hosts.txt?apikey=s3cr3t" },
|
||||
);
|
||||
try expectLine(
|
||||
"no blocklist source id for https://lists.example:8443",
|
||||
.{ .source = "https://user:pa55@lists.example:8443/hosts.txt" },
|
||||
);
|
||||
try expectLine(
|
||||
"no blocklist source id for https://lists.example",
|
||||
.{ .source = "https://lists.example/download/token/hunter2/hosts.txt" },
|
||||
);
|
||||
// What an operator still gets: the scheme, the host and the port.
|
||||
try expectLine(
|
||||
"no blocklist source id for http://10.0.0.2:8080",
|
||||
.{ .source = "http://10.0.0.2:8080/a/hosts.txt" },
|
||||
);
|
||||
}
|
||||
|
||||
test "a missing group id quotes, escapes and bounds the name" {
|
||||
try expectLine("no group id for 'kids'", .{ .group = "kids" });
|
||||
// A name is database text as well as file text, so a newline in it would end
|
||||
// this line and start one of the operator's choosing.
|
||||
try expectLine(
|
||||
"no group id for 'ads\\n2026-01-01 ERROR forged'",
|
||||
.{ .group = "ads\n2026-01-01 ERROR forged" },
|
||||
);
|
||||
// Nor can a name close the quote around it.
|
||||
try expectLine("no group id for 'kids\\' --'", .{ .group = "kids' --" });
|
||||
const long_name = "n" ** (2 * safe_url.max_len);
|
||||
try expectLine("no group id for '" ++ long_name[0..safe_url.max_len] ++ "...'", .{ .group = long_name });
|
||||
}
|
||||
|
||||
test "an InsertContext with no maps reports a missing id rather than trapping" {
|
||||
const ctx: InsertContext = .{};
|
||||
try testing.expectError(error.NotFound, ctx.groupId("default"));
|
||||
|
||||
Reference in New Issue
Block a user