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
6 changes: 3 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ Options:
--exclude <tables> Comma-separated tables to exclude
--full Copy all rows (no sampling, slower but complete)
--no-sanitize Skip PII sanitization
--allow-unsafe NULL out columns with unknown Postgres types
instead of aborting (default: abort)
-c, --config <path> Path to .sow.yml config file
--json Output as JSON events (for agents)
-q, --quiet Minimal output, no spinners
Expand Down Expand Up @@ -97,6 +99,7 @@ Examples:
exclude: { type: "string" },
full: { type: "boolean", default: false },
noSanitize: { type: "boolean", default: false },
allowUnsafe: { type: "boolean", default: false },
config: { type: "string", shortFlag: "c" },
json: { type: "boolean", default: false },
quiet: { type: "boolean", shortFlag: "q", default: false },
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/commands/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ export async function runConnect(
? merged.samplingConfig.excludeTables
: undefined,
noSanitize: !merged.sanitizationConfig.enabled,
allowUnsafe: !!flags.allowUnsafe,
seed: merged.samplingConfig.seed,
}, log);

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/branching/connector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export async function createConnector(
enabled: !opts.noSanitize,
rules: [] as { table: string; column: string; type: any }[],
skipColumns: [] as string[],
allowUnsafe: !!opts.allowUnsafe,
};

if (!opts.noSanitize) {
Expand All @@ -75,6 +76,7 @@ export async function createConnector(
const sanitizer = createSanitizer({
config: sanitizationConfig,
tables: analysis.schema.tables,
enumTypes: analysis.schema.enums,
onProgress,
});
const sanitized = sanitizer.sanitize(sampled.tables);
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/branching/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ export interface ConnectorCreateOptions {
seed?: number;
/** Copy all rows instead of sampling. Overrides maxRowsPerTable. */
full?: boolean;
/**
* When true, columns whose Postgres type the sanitizer cannot verify are
* NULLed out instead of aborting the connect. Maps to CLI `--allow-unsafe`.
*/
allowUnsafe?: boolean;
}

export interface ConnectorCreateResult {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export { analyze } from "./analyzer/index.js";
export type { AnalyzeOptions } from "./analyzer/index.js";
export { createSampler } from "./sampler/index.js";
export type { SamplerOptions } from "./sampler/index.js";
export { createSanitizer } from "./sanitizer/index.js";
export { createSanitizer, SanitizationAbort } from "./sanitizer/index.js";
export type { SanitizerOptions } from "./sanitizer/index.js";
export { createExporter } from "./exporter/index.js";
export type { ExporterOptions } from "./exporter/index.js";
Expand Down
79 changes: 78 additions & 1 deletion packages/core/src/sanitizer/detector.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { detectColumnPII, detectTablePII } from "./detector.js";
import {
detectColumnPII,
detectTablePII,
classifyPgType,
stripArraySuffix,
pgTypeToPIIType,
} from "./detector.js";

describe("detectColumnPII", () => {
describe("email detection", () => {
Expand Down Expand Up @@ -121,3 +127,74 @@ describe("detectTablePII", () => {
expect(results.every((r) => r.table === "users")).toBe(true);
});
});

describe("Postgres type classification", () => {
it("classifies known numeric/date/bool as safe", () => {
expect(classifyPgType("int4")).toBe("safe");
expect(classifyPgType("bigint")).toBe("safe");
expect(classifyPgType("timestamp")).toBe("safe");
expect(classifyPgType("boolean")).toBe("safe");
expect(classifyPgType("uuid")).toBe("safe");
expect(classifyPgType("money")).toBe("safe");
expect(classifyPgType("interval")).toBe("safe");
expect(classifyPgType("int4range")).toBe("safe");
expect(classifyPgType("bytea")).toBe("safe");
});

it("classifies text-like types as handled", () => {
expect(classifyPgType("text")).toBe("handled");
expect(classifyPgType("varchar")).toBe("handled");
expect(classifyPgType("citext")).toBe("handled");
});

it("classifies jsonb/inet/macaddr/xml as handled", () => {
expect(classifyPgType("jsonb")).toBe("handled");
expect(classifyPgType("json")).toBe("handled");
expect(classifyPgType("inet")).toBe("handled");
expect(classifyPgType("cidr")).toBe("handled");
expect(classifyPgType("macaddr")).toBe("handled");
expect(classifyPgType("xml")).toBe("handled");
});

it("classifies pg_lsn / hstore / tsvector as unknown", () => {
expect(classifyPgType("pg_lsn")).toBe("unknown");
expect(classifyPgType("hstore")).toBe("unknown");
expect(classifyPgType("tsvector")).toBe("unknown");
});

it("treats custom enum types as safe when provided in the enum set", () => {
const enums = new Set(["user_role"]);
expect(classifyPgType("user_role", enums)).toBe("safe");
expect(classifyPgType("user_role")).toBe("unknown");
});

it("strips array suffix and classifies via base type", () => {
expect(stripArraySuffix("text[]")).toEqual({ baseType: "text", isArray: true });
expect(stripArraySuffix("_int4")).toEqual({ baseType: "int4", isArray: true });
expect(stripArraySuffix("text")).toEqual({ baseType: "text", isArray: false });
expect(classifyPgType("text[]")).toBe("handled");
expect(classifyPgType("int4[]")).toBe("safe");
});

it("maps jsonb/inet/xml/bytea/macaddr to an intrinsic PIIType", () => {
expect(pgTypeToPIIType("jsonb")).toBe("jsonb");
expect(pgTypeToPIIType("inet")).toBe("ip_address");
expect(pgTypeToPIIType("cidr")).toBe("ip_address");
expect(pgTypeToPIIType("macaddr")).toBe("mac_address");
expect(pgTypeToPIIType("xml")).toBe("xml_text");
expect(pgTypeToPIIType("bytea")).toBe("binary_blob");
expect(pgTypeToPIIType("text")).toBeNull();
});

it("detects a jsonb column as PII by type alone", () => {
const result = detectColumnPII("metadata", "jsonb", []);
expect(result.isPII).toBe(true);
expect(result.type).toBe("jsonb");
});

it("detects an inet column as ip_address by type alone", () => {
const result = detectColumnPII("last_seen_from", "inet", []);
expect(result.isPII).toBe(true);
expect(result.type).toBe("ip_address");
});
});
113 changes: 113 additions & 0 deletions packages/core/src/sanitizer/detector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,106 @@ function matchValues(
*/
const NUMERIC_TYPES = /^(int|float|double|decimal|numeric|real|serial|bigint|smallint|money|int2|int4|int8|float4|float8)/i;

/**
* Postgres types that are inherently safe: no PII, no transformation needed,
* can be copied to the sandbox verbatim. Used by the fail-closed gate.
*/
const SAFE_PASSTHROUGH_TYPES = new Set([
// numeric
"int", "int2", "int4", "int8", "smallint", "integer", "bigint",
"serial", "bigserial", "smallserial",
"real", "float4", "float8", "double precision", "numeric", "decimal",
"money",
// boolean
"bool", "boolean",
// date/time
"date", "time", "timetz", "timestamp", "timestamptz",
"interval",
// uuid (sanitized separately if name matches)
"uuid",
// binary / large
"bytea",
// ranges
"int4range", "int8range", "numrange", "tsrange", "tstzrange", "daterange",
// geometric / network / other
"point", "line", "lseg", "box", "path", "polygon", "circle",
// bit
"bit", "varbit",
// oid family
"oid", "regproc", "regclass", "regtype",
]);

/**
* Postgres types the sanitizer actively handles via the detector
* (either through value-pattern matching on text, or a dedicated transformer).
*/
const HANDLED_TEXT_TYPES = new Set([
"text", "varchar", "char", "character", "character varying", "citext", "name",
]);

const HANDLED_SPECIAL_TYPES = new Set([
"jsonb", "json",
"inet", "cidr",
"macaddr", "macaddr8",
"xml",
]);

/** Strip the `[]` suffix (or leading `_`) from a Postgres array type. */
export function stripArraySuffix(pgType: string): { baseType: string; isArray: boolean } {
const t = pgType.trim();
if (t.endsWith("[]")) return { baseType: t.slice(0, -2), isArray: true };
if (t.startsWith("_")) return { baseType: t.slice(1), isArray: true };
return { baseType: t, isArray: false };
}

/**
* Classify a Postgres type for the fail-closed sanitization gate.
*
* - "safe": known non-PII, passthrough OK
* - "handled": the sanitizer has a detector or transformer for this type
* - "unknown": not in any known set — must be reported to the gate
*/
export function classifyPgType(
pgType: string,
knownEnumTypes: Set<string> = new Set(),
): "safe" | "handled" | "unknown" {
const normalized = pgType.trim().toLowerCase();
const { baseType } = stripArraySuffix(normalized);

if (SAFE_PASSTHROUGH_TYPES.has(baseType)) return "safe";
if (HANDLED_TEXT_TYPES.has(baseType)) return "handled";
if (HANDLED_SPECIAL_TYPES.has(baseType)) return "handled";
if (NUMERIC_TYPES.test(baseType)) return "safe";
if (knownEnumTypes.has(baseType)) return "safe";
// Common qualified enum names like "public.user_role"
if (knownEnumTypes.has(baseType.replace(/^public\./, ""))) return "safe";
return "unknown";
}

/** Map a Postgres data_type string to a PIIType when that mapping is intrinsic
* to the type (ignoring column name). Used both by the gate and the detector.
*/
export function pgTypeToPIIType(pgType: string): import("../types.js").PIIType | null {
const { baseType } = stripArraySuffix(pgType.trim().toLowerCase());
switch (baseType) {
case "jsonb":
case "json":
return "jsonb";
case "inet":
case "cidr":
return "ip_address";
case "macaddr":
case "macaddr8":
return "mac_address";
case "xml":
return "xml_text";
case "bytea":
return "binary_blob";
default:
return null;
}
}

export function detectColumnPII(
columnName: string,
columnType: string,
Expand All @@ -54,6 +154,19 @@ export function detectColumnPII(
}
}

// Type-intrinsic PII (jsonb, inet, macaddr, xml, etc.) — no column name needed.
const intrinsic = pgTypeToPIIType(columnType);
if (intrinsic) {
return {
isPII: true,
type: intrinsic,
confidence: "high",
matchedBy: "column_name",
sampleMatches: 0,
totalSampled: sampleValues.length,
};
}

if (NUMERIC_TYPES.test(columnType)) {
return {
isPII: false,
Expand Down
Loading
Loading