@suluk/harden 0.1.0 → 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/package.json +2 -2
- package/src/harden.ts +64 -0
- package/src/index.ts +2 -0
- package/test/harden.test.ts +47 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suluk/harden",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Schema HARDENING as a derived contract facet: audit a v4 'Suluk' document's input schemas for the validations that keep weird/oversized input from breaking the system — every string a maxLength + a pattern, every number a maximum, every array a maxItems, every object closed + typed, no any/unknown — score it (A-F), surface the grade to INCENTIVISE the author, and gate CI on a minimum. CANDIDATE tooling.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
".": "./src/index.ts"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@suluk/core": "^0.1.
|
|
22
|
+
"@suluk/core": "^0.1.11"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"@types/bun": "latest"
|
package/src/harden.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hardening TRANSFORM — the answer to {@link auditDocument}'s findings. It adds sensible default BOUNDS so
|
|
3
|
+
* untrusted input can't be unboundedly large or carry control characters that break parsers: every string gets a
|
|
4
|
+
* maxLength + a control-char-rejecting pattern, every number a maximum/minimum, every array a maxItems, every object
|
|
5
|
+
* is closed (additionalProperties:false). Authors TIGHTEN per field (a slug isn't 1024 chars) — this is the floor
|
|
6
|
+
* that turns an F/D contract into a B. The inverse of the audit: audit grades the gaps, harden fills them.
|
|
7
|
+
*/
|
|
8
|
+
import type { OpenAPIv4Document } from "@suluk/core";
|
|
9
|
+
|
|
10
|
+
type S = Record<string, unknown>;
|
|
11
|
+
|
|
12
|
+
/** Overridable floors — defaults match the baseline (1024 chars / ±1e12 / 1000 items / no control chars). */
|
|
13
|
+
export interface HardenOptions {
|
|
14
|
+
maxLength?: number;
|
|
15
|
+
/** reject NUL + control chars (tab/newline/CR allowed). Pass null to skip adding a pattern. */
|
|
16
|
+
textPattern?: string | null;
|
|
17
|
+
numberMax?: number;
|
|
18
|
+
numberMin?: number;
|
|
19
|
+
maxItems?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Reject NUL + control chars that break parsers (tab/newline/CR are allowed by the range below).
|
|
23
|
+
const SAFE_TEXT = "^[^\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f]*$";
|
|
24
|
+
const DEFAULTS: Required<HardenOptions> = { maxLength: 1024, textPattern: SAFE_TEXT, numberMax: 1_000_000_000_000, numberMin: -1_000_000_000_000, maxItems: 1000 };
|
|
25
|
+
|
|
26
|
+
/** Recursively add baseline bounds to a JSON Schema. Idempotent — never overrides an author-set bound. */
|
|
27
|
+
export function hardenSchema(schema: unknown, opts: HardenOptions = {}): unknown {
|
|
28
|
+
const o = { ...DEFAULTS, ...opts };
|
|
29
|
+
const go = (sch: unknown): unknown => {
|
|
30
|
+
if (sch == null || typeof sch !== "object") return sch;
|
|
31
|
+
if (Array.isArray(sch)) return sch.map(go);
|
|
32
|
+
const s: S = { ...(sch as S) };
|
|
33
|
+
const t = Array.isArray(s.type) ? (s.type as string[])[0] : s.type;
|
|
34
|
+
if (s.properties) { const p: S = {}; for (const [k, v] of Object.entries(s.properties as S)) p[k] = go(v); s.properties = p; if (s.additionalProperties === undefined) s.additionalProperties = false; }
|
|
35
|
+
if (s.items) s.items = go(s.items);
|
|
36
|
+
for (const key of ["oneOf", "anyOf", "allOf"] as const) if (Array.isArray(s[key])) s[key] = (s[key] as unknown[]).map(go);
|
|
37
|
+
const bounded = s.enum !== undefined || s.const !== undefined || s.format !== undefined;
|
|
38
|
+
if (t === "string" && !bounded) {
|
|
39
|
+
if (s.maxLength === undefined) s.maxLength = o.maxLength;
|
|
40
|
+
if (s.pattern === undefined && o.textPattern != null) s.pattern = o.textPattern;
|
|
41
|
+
}
|
|
42
|
+
if (t === "integer" || t === "number") {
|
|
43
|
+
if (s.maximum === undefined && s.exclusiveMaximum === undefined) s.maximum = o.numberMax;
|
|
44
|
+
if (s.minimum === undefined && s.exclusiveMinimum === undefined) s.minimum = o.numberMin;
|
|
45
|
+
}
|
|
46
|
+
if (t === "array" && s.maxItems === undefined) s.maxItems = o.maxItems;
|
|
47
|
+
return s;
|
|
48
|
+
};
|
|
49
|
+
return go(schema);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Harden EVERY input schema in a built v4 document IN PLACE — request bodies + all parameter slots (incl. the route
|
|
53
|
+
* generator's path params, otherwise unbounded strings). Idempotent. The transform that makes assertGrade pass. */
|
|
54
|
+
export function hardenDocument<T extends OpenAPIv4Document>(doc: T, opts: HardenOptions = {}): T {
|
|
55
|
+
const d = doc as unknown as { paths?: Record<string, { requests?: Record<string, Record<string, unknown>> }> };
|
|
56
|
+
for (const pi of Object.values(d.paths ?? {})) {
|
|
57
|
+
for (const req of Object.values(pi.requests ?? {})) {
|
|
58
|
+
if (req.contentSchema) req.contentSchema = hardenSchema(req.contentSchema, opts);
|
|
59
|
+
const ps = req.parameterSchema as Record<string, unknown> | undefined;
|
|
60
|
+
if (ps) for (const loc of ["query", "path", "header", "cookie", "body"]) if (ps[loc]) ps[loc] = hardenSchema(ps[loc], opts);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return doc;
|
|
64
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -11,3 +11,5 @@ export {
|
|
|
11
11
|
auditDocument, auditOperation, assertGrade, grade,
|
|
12
12
|
type Audit, type OpAudit, type DocAudit, type Finding, type Severity, type Grade,
|
|
13
13
|
} from "./audit";
|
|
14
|
+
// the inverse of the audit — the hardening TRANSFORM that adds the baseline bounds the audit grades for.
|
|
15
|
+
export { hardenSchema, hardenDocument, type HardenOptions } from "./harden";
|
package/test/harden.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { test, expect, describe } from "bun:test";
|
|
2
|
-
import { auditOperation, auditDocument, assertGrade, grade } from "../src/index";
|
|
2
|
+
import { auditOperation, auditDocument, assertGrade, grade, hardenSchema, hardenDocument } from "../src/index";
|
|
3
3
|
import type { OpenAPIv4Document } from "@suluk/core";
|
|
4
4
|
|
|
5
5
|
const weakReq = { method: "post", contentSchema: { type: "object", properties: {
|
|
@@ -63,3 +63,49 @@ describe("@suluk/harden — schema hardening as a scored facet", () => {
|
|
|
63
63
|
expect(d.findings.filter((f) => f.rule === "string-max-length").length).toBe(1);
|
|
64
64
|
});
|
|
65
65
|
});
|
|
66
|
+
|
|
67
|
+
describe("hardenSchema / hardenDocument — the transform (inverse of the audit)", () => {
|
|
68
|
+
test("adds baseline bounds: string→maxLength+pattern, number→min/max, array→maxItems, object→closed", () => {
|
|
69
|
+
const h = hardenSchema({ type: "object", properties: {
|
|
70
|
+
name: { type: "string" }, age: { type: "integer" }, tags: { type: "array", items: { type: "string" } },
|
|
71
|
+
meta: { type: "object", properties: { k: { type: "string" } } },
|
|
72
|
+
} }) as Record<string, any>;
|
|
73
|
+
expect(h.additionalProperties).toBe(false);
|
|
74
|
+
expect(h.properties.name.maxLength).toBe(1024);
|
|
75
|
+
expect(h.properties.name.pattern).toContain("u0000");
|
|
76
|
+
expect(h.properties.age.maximum).toBe(1_000_000_000_000);
|
|
77
|
+
expect(h.properties.age.minimum).toBe(-1_000_000_000_000);
|
|
78
|
+
expect(h.properties.tags.maxItems).toBe(1000);
|
|
79
|
+
expect(h.properties.tags.items.maxLength).toBe(1024);
|
|
80
|
+
expect(h.properties.meta.additionalProperties).toBe(false); // nested objects WITH properties get closed
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("never overrides an author-set bound, and leaves enum/const/format strings alone", () => {
|
|
84
|
+
const h = hardenSchema({ type: "string", maxLength: 64 }) as Record<string, unknown>;
|
|
85
|
+
expect(h.maxLength).toBe(64);
|
|
86
|
+
expect(hardenSchema({ type: "string", enum: ["a", "b"] })).not.toHaveProperty("maxLength");
|
|
87
|
+
expect(hardenSchema({ type: "string", format: "email" })).not.toHaveProperty("pattern");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("respects overridable floors", () => {
|
|
91
|
+
const h = hardenSchema({ type: "string" }, { maxLength: 80, textPattern: null }) as Record<string, unknown>;
|
|
92
|
+
expect(h.maxLength).toBe(80);
|
|
93
|
+
expect(h).not.toHaveProperty("pattern");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("INVERSE PROPERTY: hardenDocument fills the BOUND gaps → a bounds-only-weak doc then passes assertGrade('A')", () => {
|
|
97
|
+
// gaps are ONLY missing bounds (every field typed, objects have properties) — exactly what the floor can fill.
|
|
98
|
+
const boundsOnly = { method: "post", contentSchema: { type: "object", properties: {
|
|
99
|
+
name: { type: "string" }, age: { type: "integer" }, tags: { type: "array", items: { type: "string" } },
|
|
100
|
+
} } };
|
|
101
|
+
const doc2 = { openapi: "4.0.0-candidate", info: { title: "T" }, paths: { w: { requests: { createW: boundsOnly } } } } as unknown as OpenAPIv4Document;
|
|
102
|
+
expect(() => assertGrade(doc2, "A")).toThrow(); // unbounded today
|
|
103
|
+
hardenDocument(doc2); // in place
|
|
104
|
+
expect(assertGrade(doc2, "A").grade).toBe("A"); // the audit's gaps are now filled
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("idempotent — hardening twice is a no-op", () => {
|
|
108
|
+
const once = hardenSchema({ type: "object", properties: { a: { type: "string" } } });
|
|
109
|
+
expect(hardenSchema(once)).toEqual(once);
|
|
110
|
+
});
|
|
111
|
+
});
|