@loomwork/classify 0.0.0-stage → 0.1.1
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/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +73 -2
- package/dist/classify.d.ts +47 -0
- package/dist/classify.d.ts.map +1 -0
- package/dist/classify.js +147 -0
- package/dist/classify.js.map +1 -0
- package/dist/esm/classify.js +135 -0
- package/dist/esm/classify.js.map +1 -0
- package/dist/esm/expand.js +87 -0
- package/dist/esm/expand.js.map +1 -0
- package/dist/esm/external.js +33 -0
- package/dist/esm/external.js.map +1 -0
- package/dist/esm/index.js +7 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/redact.js +56 -0
- package/dist/esm/redact.js.map +1 -0
- package/dist/esm/taint.js +142 -0
- package/dist/esm/taint.js.map +1 -0
- package/dist/esm/unreachable.js +126 -0
- package/dist/esm/unreachable.js.map +1 -0
- package/dist/expand.d.ts +16 -0
- package/dist/expand.d.ts.map +1 -0
- package/dist/expand.js +91 -0
- package/dist/expand.js.map +1 -0
- package/dist/external.d.ts +15 -0
- package/dist/external.d.ts.map +1 -0
- package/dist/external.js +38 -0
- package/dist/external.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +38 -0
- package/dist/index.js.map +1 -0
- package/dist/redact.d.ts +4 -0
- package/dist/redact.d.ts.map +1 -0
- package/dist/redact.js +60 -0
- package/dist/redact.js.map +1 -0
- package/dist/taint.d.ts +85 -0
- package/dist/taint.d.ts.map +1 -0
- package/dist/taint.js +152 -0
- package/dist/taint.js.map +1 -0
- package/dist/unreachable.d.ts +20 -0
- package/dist/unreachable.d.ts.map +1 -0
- package/dist/unreachable.js +129 -0
- package/dist/unreachable.js.map +1 -0
- package/package.json +55 -3
- package/src/classify.ts +175 -0
- package/src/expand.ts +97 -0
- package/src/external.ts +40 -0
- package/src/index.ts +31 -0
- package/src/redact.ts +59 -0
- package/src/taint.ts +189 -0
- package/src/unreachable.ts +164 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import { type DataClass, classOf } from "./classify";
|
|
3
|
+
|
|
4
|
+
// ── Marks the dot-path sinks cannot reach ─────────────────────────────────────
|
|
5
|
+
//
|
|
6
|
+
// `collectMarkedPaths` walks objects and — since #260 option 2 — arrays, via a `[]` crossing
|
|
7
|
+
// segment. Everything else it simply does not descend: a record's values, a union's branches,
|
|
8
|
+
// a tuple's members, an intersection's sides. A mark placed under one of those collects no
|
|
9
|
+
// path, so `maskPaths` never masks it, the crypto field walker never encrypts it, and the
|
|
10
|
+
// content externalizer never externalizes it. The developer marked the field; the mark does
|
|
11
|
+
// nothing, and nothing says so. This finds those marks so a pre-flight check can.
|
|
12
|
+
//
|
|
13
|
+
// It mirrors the walker it audits — same transparent wrappers, same "a marked object is a
|
|
14
|
+
// unit, don't descend" rule, same array traversal — so the two agree on what IS reachable.
|
|
15
|
+
// Divergence would produce false positives on fields that are in fact masked, which is worse
|
|
16
|
+
// than the gap being reported.
|
|
17
|
+
|
|
18
|
+
/** A classification mark that no dot-path sink can address. */
|
|
19
|
+
export interface UnreachableMark {
|
|
20
|
+
/**
|
|
21
|
+
* Dot-path to the mark, with `[]` per array crossing and `<Type>` where an untraversable
|
|
22
|
+
* container sits — e.g. `"byId.<record>.ssn"`. Not an addressable path; a location.
|
|
23
|
+
*/
|
|
24
|
+
readonly path: string;
|
|
25
|
+
readonly dataClass: DataClass;
|
|
26
|
+
/** The container that blocks it, lowercased without the `Zod` prefix (`record`, `union`, …). */
|
|
27
|
+
readonly container: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Unwrap optional/nullable/default/effects, as the classification walker does. */
|
|
31
|
+
function chain(schema: z.ZodTypeAny): { nodes: z.ZodTypeAny[]; inner: z.ZodTypeAny } {
|
|
32
|
+
const nodes: z.ZodTypeAny[] = [];
|
|
33
|
+
let cur: z.ZodTypeAny = schema;
|
|
34
|
+
for (let i = 0; i < 100; i++) {
|
|
35
|
+
nodes.push(cur);
|
|
36
|
+
const def = (cur as { _def?: Record<string, unknown> })._def;
|
|
37
|
+
const typeName = def?.typeName as string | undefined;
|
|
38
|
+
let next: unknown;
|
|
39
|
+
if (typeName === "ZodOptional" || typeName === "ZodNullable" || typeName === "ZodDefault") {
|
|
40
|
+
next = def?.innerType;
|
|
41
|
+
} else if (typeName === "ZodEffects") {
|
|
42
|
+
next = def?.schema;
|
|
43
|
+
} else {
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
if (next === undefined) break;
|
|
47
|
+
cur = next as z.ZodTypeAny;
|
|
48
|
+
}
|
|
49
|
+
return { nodes, inner: cur };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function typeNameOf(node: z.ZodTypeAny): string | undefined {
|
|
53
|
+
return (node as { _def?: { typeName?: string } })._def?.typeName;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function objectShape(node: z.ZodTypeAny): Record<string, z.ZodTypeAny> | undefined {
|
|
57
|
+
const def = (node as { _def?: { typeName?: string; shape?: () => unknown } })._def;
|
|
58
|
+
if (def?.typeName !== "ZodObject" || typeof def.shape !== "function") return undefined;
|
|
59
|
+
return def.shape() as Record<string, z.ZodTypeAny>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function arrayElement(node: z.ZodTypeAny): z.ZodTypeAny | undefined {
|
|
63
|
+
const def = (node as { _def?: { typeName?: string; type?: unknown } })._def;
|
|
64
|
+
if (def?.typeName !== "ZodArray") return undefined;
|
|
65
|
+
return def.type as z.ZodTypeAny | undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Schemas that hold inner schemas the classification walker never descends into. Returns the
|
|
70
|
+
* children to search for stranded marks, plus the label for the location string.
|
|
71
|
+
*
|
|
72
|
+
* `lazy` is deliberately absent: descending a recursive schema to hunt for marks risks
|
|
73
|
+
* non-termination, and the payoff is small.
|
|
74
|
+
*/
|
|
75
|
+
function opaqueChildren(
|
|
76
|
+
node: z.ZodTypeAny
|
|
77
|
+
): { label: string; children: z.ZodTypeAny[] } | undefined {
|
|
78
|
+
const def = (node as { _def?: Record<string, unknown> })._def;
|
|
79
|
+
const typeName = typeNameOf(node);
|
|
80
|
+
switch (typeName) {
|
|
81
|
+
case "ZodRecord":
|
|
82
|
+
case "ZodMap":
|
|
83
|
+
case "ZodSet":
|
|
84
|
+
return {
|
|
85
|
+
label: typeName.slice(3).toLowerCase(),
|
|
86
|
+
children: [def?.valueType as z.ZodTypeAny].filter(Boolean),
|
|
87
|
+
};
|
|
88
|
+
case "ZodUnion":
|
|
89
|
+
case "ZodDiscriminatedUnion": {
|
|
90
|
+
const options = def?.options;
|
|
91
|
+
const list = Array.isArray(options)
|
|
92
|
+
? options
|
|
93
|
+
: [...((options as Map<unknown, unknown>)?.values?.() ?? [])];
|
|
94
|
+
return { label: "union", children: list as z.ZodTypeAny[] };
|
|
95
|
+
}
|
|
96
|
+
case "ZodTuple":
|
|
97
|
+
return { label: "tuple", children: (def?.items as z.ZodTypeAny[]) ?? [] };
|
|
98
|
+
case "ZodIntersection":
|
|
99
|
+
return {
|
|
100
|
+
label: "intersection",
|
|
101
|
+
children: [def?.left as z.ZodTypeAny, def?.right as z.ZodTypeAny].filter(Boolean),
|
|
102
|
+
};
|
|
103
|
+
default:
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function effectiveClass(schema: z.ZodTypeAny): DataClass | undefined {
|
|
109
|
+
for (const node of chain(schema).nodes) {
|
|
110
|
+
const mark = classOf(node);
|
|
111
|
+
if (mark !== undefined) return mark;
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Every classification mark stranded under a container the dot-path sinks cannot address.
|
|
118
|
+
*
|
|
119
|
+
* Returns `[]` for marks on objects and arrays — both are traversable, so those marks work.
|
|
120
|
+
*/
|
|
121
|
+
export function collectUnreachableMarks(schema: z.ZodTypeAny, prefix = ""): UnreachableMark[] {
|
|
122
|
+
return walk(schema, prefix, undefined);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function walk(
|
|
126
|
+
schema: z.ZodTypeAny,
|
|
127
|
+
path: string,
|
|
128
|
+
blockedBy: string | undefined
|
|
129
|
+
): UnreachableMark[] {
|
|
130
|
+
const mark = effectiveClass(schema);
|
|
131
|
+
if (mark !== undefined && path !== "") {
|
|
132
|
+
// A mark terminates the walk exactly as it does in `collectMarkedPaths` — a marked object
|
|
133
|
+
// is a unit — so a mark nested inside a marked one is not double-reported.
|
|
134
|
+
return blockedBy === undefined ? [] : [{ path, dataClass: mark, container: blockedBy }];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const inner = chain(schema).inner;
|
|
138
|
+
|
|
139
|
+
const element = arrayElement(inner);
|
|
140
|
+
if (element !== undefined) return walk(element, `${path}[]`, blockedBy);
|
|
141
|
+
|
|
142
|
+
const shape = objectShape(inner);
|
|
143
|
+
if (shape !== undefined) {
|
|
144
|
+
const out: UnreachableMark[] = [];
|
|
145
|
+
for (const [key, child] of Object.entries(shape)) {
|
|
146
|
+
out.push(...walk(child, path === "" ? key : `${path}.${key}`, blockedBy));
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const opaque = opaqueChildren(inner);
|
|
152
|
+
if (opaque !== undefined) {
|
|
153
|
+
const out: UnreachableMark[] = [];
|
|
154
|
+
// Everything below here is unreachable — but keep the OUTERMOST blocker, since that is
|
|
155
|
+
// the one the developer has to restructure.
|
|
156
|
+
const blocker = blockedBy ?? opaque.label;
|
|
157
|
+
for (const child of opaque.children) {
|
|
158
|
+
out.push(...walk(child, `${path}.<${opaque.label}>`, blocker));
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return [];
|
|
164
|
+
}
|