@suluk/harden 0.1.0
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 +31 -0
- package/src/audit.ts +127 -0
- package/src/index.ts +13 -0
- package/test/harden.test.ts +65 -0
- package/tsconfig.json +1 -0
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@suluk/harden",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"license": "Apache-2.0",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/MahmoodKhalil57/suluk.git",
|
|
12
|
+
"directory": "tooling/ts/packages/harden"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/MahmoodKhalil57/suluk#readme",
|
|
15
|
+
"bugs": "https://github.com/MahmoodKhalil57/suluk/issues",
|
|
16
|
+
"type": "module",
|
|
17
|
+
"main": "src/index.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./src/index.ts"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@suluk/core": "^0.1.7"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/bun": "latest"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "bun test",
|
|
29
|
+
"typecheck": "tsc --noEmit -p ."
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/audit.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema HARDENING audit — a derived facet (like cost/access) that scores how well a v4 document's INPUT schemas
|
|
3
|
+
* bound untrusted input, the validations that keep weird/oversized data from breaking the system:
|
|
4
|
+
* • no `any`/`unknown` — every input has a determinable type (or enum/const)
|
|
5
|
+
* • every string a maxLength AND a pattern (a character allowlist) — unless bounded by enum/const/format
|
|
6
|
+
* • every number a maximum (and ideally a minimum)
|
|
7
|
+
* • every array a maxItems (a DoS guard)
|
|
8
|
+
* • every object CLOSED (additionalProperties:false) and TYPED (defined properties)
|
|
9
|
+
* It produces per-operation + document scores + a letter grade, concrete fixes, and a CI gate — to INCENTIVISE the
|
|
10
|
+
* author to harden the contract. Audits the request body + typed parameter slots (the attack surface).
|
|
11
|
+
*/
|
|
12
|
+
import type { OpenAPIv4Document } from "@suluk/core";
|
|
13
|
+
import { isReference, deref } from "@suluk/core";
|
|
14
|
+
|
|
15
|
+
export type Severity = "high" | "medium" | "low";
|
|
16
|
+
export type Grade = "A" | "B" | "C" | "D" | "F";
|
|
17
|
+
export interface Finding { rule: string; severity: Severity; path: string; message: string; fix: string }
|
|
18
|
+
export interface Audit { findings: Finding[]; nodes: number; clean: number; score: number; grade: Grade }
|
|
19
|
+
export interface OpAudit extends Audit { operation: string; method: string; path: string }
|
|
20
|
+
export interface DocAudit extends Audit { byOperation: OpAudit[]; bySeverity: Record<Severity, number> }
|
|
21
|
+
|
|
22
|
+
export function grade(score: number): Grade { return score >= 90 ? "A" : score >= 75 ? "B" : score >= 60 ? "C" : score >= 40 ? "D" : "F"; }
|
|
23
|
+
|
|
24
|
+
interface Acc { findings: Finding[]; nodes: Map<string, boolean> } // path → clean
|
|
25
|
+
|
|
26
|
+
const has = (o: Record<string, unknown>, k: string) => o[k] != null;
|
|
27
|
+
|
|
28
|
+
function walk(doc: OpenAPIv4Document, schema: unknown, path: string, seen: Set<string>, acc: Acc): void {
|
|
29
|
+
if (schema == null || schema === false) return; // absent / `never` — nothing to audit
|
|
30
|
+
if (isReference(schema)) {
|
|
31
|
+
const id = String((schema as { $ref: string }).$ref);
|
|
32
|
+
if (seen.has(id)) return;
|
|
33
|
+
let r: unknown; try { r = deref(doc, schema); } catch { return; } // dangling — the renderer flags it; skip here
|
|
34
|
+
if (r && !isReference(r)) walk(doc, r, id.replace(/^#\//, ""), new Set([...seen, id]), acc); // model-rooted path → dedupe across ops
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (schema === true) { mark(acc, path, false); acc.findings.push({ rule: "no-any", severity: "high", path, message: `'${path}' is \`true\` — permits ANY value`, fix: "replace with a typed, bounded schema" }); return; }
|
|
38
|
+
const s = schema as Record<string, unknown>;
|
|
39
|
+
if (Array.isArray(s.oneOf) || Array.isArray(s.anyOf) || Array.isArray(s.allOf)) { for (const b of (s.oneOf ?? s.anyOf ?? s.allOf) as unknown[]) walk(doc, b, `${path}/~`, seen, acc); return; }
|
|
40
|
+
if (s.enum !== undefined || s.const !== undefined) { mark(acc, path, true); return; } // bounded by an explicit value set → clean
|
|
41
|
+
|
|
42
|
+
const types = Array.isArray(s.type) ? (s.type as string[]) : s.type ? [s.type as string] : [];
|
|
43
|
+
mark(acc, path, true); // optimistic; failures flip it
|
|
44
|
+
const fail = (rule: string, sev: Severity, message: string, fix: string) => { acc.findings.push({ rule, severity: sev, path, message, fix }); mark(acc, path, false); };
|
|
45
|
+
|
|
46
|
+
if (!types.length) { fail("no-any", "high", `'${path}' has no \`type\` (any/unknown)`, "give it a concrete type + bounds — never accept unconstrained input"); return; }
|
|
47
|
+
for (const t of types) {
|
|
48
|
+
if (t === "string") {
|
|
49
|
+
const bounded = has(s, "format");
|
|
50
|
+
if (!has(s, "maxLength") && !bounded) fail("string-max-length", "high", `string '${path}' has no maxLength`, "add maxLength (e.g. 256) so the field can't be unboundedly large");
|
|
51
|
+
if (!has(s, "pattern") && !bounded) fail("string-pattern", "medium", `string '${path}' has no pattern`, "add a pattern allowlisting characters (e.g. ^[\\w .@-]{0,256}$) to reject weird/injection input");
|
|
52
|
+
} else if (t === "integer" || t === "number") {
|
|
53
|
+
if (!has(s, "maximum") && !has(s, "exclusiveMaximum")) fail("number-maximum", "medium", `number '${path}' has no maximum`, "add a maximum to bound magnitude / digit count");
|
|
54
|
+
if (!has(s, "minimum") && !has(s, "exclusiveMinimum")) fail("number-minimum", "low", `number '${path}' has no minimum`, "add a minimum");
|
|
55
|
+
} else if (t === "array") {
|
|
56
|
+
if (!has(s, "maxItems")) fail("array-max-items", "high", `array '${path}' has no maxItems`, "add maxItems to cap array size (DoS guard)");
|
|
57
|
+
walk(doc, s.items, `${path}[]`, seen, acc);
|
|
58
|
+
} else if (t === "object") {
|
|
59
|
+
const open = s.additionalProperties !== false;
|
|
60
|
+
if (open) fail("object-closed", "medium", `object '${path}' allows additionalProperties`, "set additionalProperties:false to forbid unexpected keys");
|
|
61
|
+
const props = (s.properties ?? {}) as Record<string, unknown>;
|
|
62
|
+
if (Object.keys(props).length) for (const [k, v] of Object.entries(props)) walk(doc, v, `${path}/${k}`, seen, acc);
|
|
63
|
+
else if (open) fail("object-typed", "medium", `object '${path}' has no defined properties (a free-form bag)`, "define properties with explicit types (a closed empty object additionalProperties:false is fine for no-input)");
|
|
64
|
+
// a CLOSED object with no properties (additionalProperties:false) accepts only {} → fully bounded, clean.
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function mark(acc: Acc, path: string, clean: boolean) { acc.nodes.set(path, (acc.nodes.get(path) ?? true) && clean); }
|
|
70
|
+
|
|
71
|
+
function score(acc: Acc): Audit {
|
|
72
|
+
const nodes = acc.nodes.size;
|
|
73
|
+
const clean = [...acc.nodes.values()].filter(Boolean).length;
|
|
74
|
+
const sc = nodes === 0 ? 100 : Math.round((clean / nodes) * 100);
|
|
75
|
+
// dedupe findings by rule@path (a shared model audited under many ops collapses)
|
|
76
|
+
const seen = new Set<string>();
|
|
77
|
+
const findings = acc.findings.filter((f) => { const k = `${f.rule}@${f.path}`; if (seen.has(k)) return false; seen.add(k); return true; });
|
|
78
|
+
return { findings, nodes, clean, score: sc, grade: grade(sc) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface RawReq { method: string; contentSchema?: unknown; parameterSchema?: Record<string, unknown> }
|
|
82
|
+
|
|
83
|
+
/** Audit one request's INPUT surface (request body + typed parameter slots). */
|
|
84
|
+
export function auditOperation(doc: OpenAPIv4Document, uri: string, name: string, req: RawReq): OpAudit {
|
|
85
|
+
const acc: Acc = { findings: [], nodes: new Map() };
|
|
86
|
+
const ps = req.parameterSchema ?? {};
|
|
87
|
+
walk(doc, req.contentSchema ?? ps.body, `${name}/body`, new Set(), acc);
|
|
88
|
+
for (const loc of ["path", "query", "header", "cookie"] as const) walk(doc, ps[loc], `${name}/${loc}`, new Set(), acc);
|
|
89
|
+
return { ...score(acc), operation: name, method: req.method.toLowerCase(), path: uri };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface AuditOptions {
|
|
93
|
+
/** skip operations (e.g. third-party/ingested surfaces you don't author) — they don't count toward the grade. */
|
|
94
|
+
ignore?: (uri: string, name: string) => boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Audit the document's input surface → per-op grades + a deduped rollup + a severity breakdown. */
|
|
98
|
+
export function auditDocument(doc: OpenAPIv4Document, opts: AuditOptions = {}): DocAudit {
|
|
99
|
+
const byOperation: OpAudit[] = [];
|
|
100
|
+
const acc: Acc = { findings: [], nodes: new Map() };
|
|
101
|
+
for (const [uri, piRaw] of Object.entries(doc.paths ?? {})) {
|
|
102
|
+
const pi = piRaw as { requests?: Record<string, RawReq> };
|
|
103
|
+
for (const [name, req] of Object.entries(pi.requests ?? {})) {
|
|
104
|
+
if (opts.ignore?.(uri, name)) continue;
|
|
105
|
+
byOperation.push(auditOperation(doc, uri, name, req));
|
|
106
|
+
// accumulate into the doc-wide map (model-rooted paths dedupe shared schemas)
|
|
107
|
+
const ps = req.parameterSchema ?? {};
|
|
108
|
+
walk(doc, req.contentSchema ?? ps.body, `${name}/body`, new Set(), acc);
|
|
109
|
+
for (const loc of ["path", "query", "header", "cookie"] as const) walk(doc, ps[loc], `${name}/${loc}`, new Set(), acc);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const s = score(acc);
|
|
113
|
+
const bySeverity: Record<Severity, number> = { high: 0, medium: 0, low: 0 };
|
|
114
|
+
for (const f of s.findings) bySeverity[f.severity]++;
|
|
115
|
+
return { ...s, byOperation: byOperation.sort((a, b) => a.score - b.score), bySeverity };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const ORDER: Grade[] = ["F", "D", "C", "B", "A"];
|
|
119
|
+
/** CI gate (the hard incentive): throw if the document's hardening grade is below `min`. */
|
|
120
|
+
export function assertGrade(doc: OpenAPIv4Document, min: Grade, opts: AuditOptions = {}): DocAudit {
|
|
121
|
+
const a = auditDocument(doc, opts);
|
|
122
|
+
if (ORDER.indexOf(a.grade) < ORDER.indexOf(min)) {
|
|
123
|
+
const worst = a.byOperation.slice(0, 5).map((o) => `${o.operation} (${o.grade})`).join(", ");
|
|
124
|
+
throw new Error(`@suluk/harden: contract grade ${a.grade} (${a.score}/100) is below the required ${min}. ${a.bySeverity.high} high · ${a.bySeverity.medium} medium findings. Weakest: ${worst}. Add maxLength/pattern/maximum/maxItems + close objects.`);
|
|
125
|
+
}
|
|
126
|
+
return a;
|
|
127
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @suluk/harden — schema hardening as a derived, scored contract facet. Audit a v4 'Suluk' document's INPUT
|
|
3
|
+
* schemas for the validations that keep malformed/oversized input from breaking the system, grade them A–F,
|
|
4
|
+
* surface the grade to incentivise the author, and gate CI on a minimum.
|
|
5
|
+
*
|
|
6
|
+
* import { auditDocument, assertGrade } from "@suluk/harden";
|
|
7
|
+
* const report = auditDocument(doc); // { grade, score, byOperation, findings, bySeverity }
|
|
8
|
+
* assertGrade(doc, "B"); // throws if the contract is too weak (the hard incentive)
|
|
9
|
+
*/
|
|
10
|
+
export {
|
|
11
|
+
auditDocument, auditOperation, assertGrade, grade,
|
|
12
|
+
type Audit, type OpAudit, type DocAudit, type Finding, type Severity, type Grade,
|
|
13
|
+
} from "./audit";
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { auditOperation, auditDocument, assertGrade, grade } from "../src/index";
|
|
3
|
+
import type { OpenAPIv4Document } from "@suluk/core";
|
|
4
|
+
|
|
5
|
+
const weakReq = { method: "post", contentSchema: { type: "object", properties: {
|
|
6
|
+
name: { type: "string" }, // ✗ no maxLength, ✗ no pattern
|
|
7
|
+
age: { type: "integer" }, // ✗ no maximum, ✗ no minimum
|
|
8
|
+
tags: { type: "array", items: { type: "string", maxLength: 10, pattern: "^x$" } }, // ✗ no maxItems
|
|
9
|
+
meta: { type: "object" }, // ✗ open, ✗ no properties
|
|
10
|
+
anything: {}, // ✗ any
|
|
11
|
+
} } };
|
|
12
|
+
const strongReq = { method: "post", contentSchema: { type: "object", additionalProperties: false, properties: {
|
|
13
|
+
name: { type: "string", maxLength: 64, pattern: "^[\\w ]+$" },
|
|
14
|
+
age: { type: "integer", minimum: 0, maximum: 130 },
|
|
15
|
+
tags: { type: "array", maxItems: 10, items: { type: "string", maxLength: 16, pattern: "^[a-z]+$" } },
|
|
16
|
+
status: { type: "string", enum: ["draft", "published"] }, // bounded by enum → clean
|
|
17
|
+
} } };
|
|
18
|
+
const doc = { openapi: "4.0.0-candidate", info: { title: "T" }, paths: { weak: { requests: { createWeak: weakReq } }, strong: { requests: { createStrong: strongReq } } } } as unknown as OpenAPIv4Document;
|
|
19
|
+
|
|
20
|
+
describe("@suluk/harden — schema hardening as a scored facet", () => {
|
|
21
|
+
test("a weak operation collects the right findings + a low grade", () => {
|
|
22
|
+
const a = auditOperation(doc, "weak", "createWeak", weakReq);
|
|
23
|
+
const rules = new Set(a.findings.map((f) => f.rule));
|
|
24
|
+
expect(rules).toContain("no-any");
|
|
25
|
+
expect(rules).toContain("string-max-length");
|
|
26
|
+
expect(rules).toContain("string-pattern");
|
|
27
|
+
expect(rules).toContain("number-maximum");
|
|
28
|
+
expect(rules).toContain("array-max-items");
|
|
29
|
+
expect(rules).toContain("object-closed");
|
|
30
|
+
expect(rules).toContain("object-typed");
|
|
31
|
+
expect(["D", "F"]).toContain(a.grade);
|
|
32
|
+
expect(a.findings.find((f) => f.rule === "string-max-length")!.fix).toContain("maxLength");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("a fully-hardened operation scores A (enum/const + bounds count as clean)", () => {
|
|
36
|
+
const a = auditOperation(doc, "strong", "createStrong", strongReq);
|
|
37
|
+
expect(a.findings).toEqual([]);
|
|
38
|
+
expect(a.score).toBe(100);
|
|
39
|
+
expect(a.grade).toBe("A");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("auditDocument rolls up per-op grades + a severity breakdown, weakest first", () => {
|
|
43
|
+
const d = auditDocument(doc);
|
|
44
|
+
expect(d.byOperation[0].operation).toBe("createWeak"); // weakest first
|
|
45
|
+
expect(d.bySeverity.high).toBeGreaterThan(0);
|
|
46
|
+
expect(d.grade).toBe(grade(d.score));
|
|
47
|
+
expect(d.byOperation.find((o) => o.operation === "createStrong")!.grade).toBe("A");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("assertGrade is the CI gate — throws below the minimum, passes a hardened doc", () => {
|
|
51
|
+
expect(() => assertGrade(doc, "A")).toThrow(/grade .* below the required A/);
|
|
52
|
+
const strongDoc = { openapi: "4.0.0-candidate", info: { title: "T" }, paths: { strong: { requests: { createStrong: strongReq } } } } as unknown as OpenAPIv4Document;
|
|
53
|
+
expect(assertGrade(strongDoc, "A").grade).toBe("A");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("$ref'd models dedupe across operations (audited once)", () => {
|
|
57
|
+
const refDoc = { openapi: "4.0.0-candidate", info: { title: "T" },
|
|
58
|
+
paths: { a: { requests: { createA: { method: "post", contentSchema: { $ref: "#/components/schemas/Thing" } } } }, b: { requests: { createB: { method: "post", contentSchema: { $ref: "#/components/schemas/Thing" } } } } },
|
|
59
|
+
components: { schemas: { Thing: { type: "object", additionalProperties: false, properties: { x: { type: "string" } } } } },
|
|
60
|
+
} as unknown as OpenAPIv4Document;
|
|
61
|
+
const d = auditDocument(refDoc);
|
|
62
|
+
// Thing.x (no maxLength/pattern) is ONE node deduped across both ops → one string-max-length finding, not two
|
|
63
|
+
expect(d.findings.filter((f) => f.rule === "string-max-length").length).toBe(1);
|
|
64
|
+
});
|
|
65
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["bun"] }, "include": ["src", "test"] }
|