@suluk/harden 0.1.2 → 0.2.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 +3 -2
- package/src/index.ts +3 -0
- package/src/readiness.ts +79 -0
- package/test/readiness.test.ts +85 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suluk/harden",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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,8 @@
|
|
|
19
19
|
".": "./src/index.ts"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@suluk/core": "^0.1.
|
|
22
|
+
"@suluk/core": "^0.1.13",
|
|
23
|
+
"@suluk/examples": "^0.1.0"
|
|
23
24
|
},
|
|
24
25
|
"devDependencies": {
|
|
25
26
|
"@types/bun": "latest"
|
package/src/index.ts
CHANGED
|
@@ -16,3 +16,6 @@ export {
|
|
|
16
16
|
} from "./audit";
|
|
17
17
|
// the inverse of the audit — the hardening TRANSFORM that adds the baseline bounds the audit grades for.
|
|
18
18
|
export { hardenSchema, hardenDocument, type HardenOptions } from "./harden";
|
|
19
|
+
// C043: a SECOND dimension — schema-fact READINESS (computed-required, request-without-example), separate from the
|
|
20
|
+
// security grade. Fold its letter into `combineGrades` alongside the security grade + journeys coverage.
|
|
21
|
+
export { auditReadiness, type ReadinessAudit, type ReadinessOptions } from "./readiness";
|
package/src/readiness.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* READINESS audit (C043) — a SECOND, separate harden dimension (kept apart from the security `auditDocument` grade so a
|
|
3
|
+
* security score never mixes with a readiness score). It grades schema-FACT readiness the doc can answer alone — the
|
|
4
|
+
* concerns C040/C041 made expressible:
|
|
5
|
+
* • `computed-required` — a request field marked `computed` (or `readOnly`) AND `required`: a client CANNOT send it,
|
|
6
|
+
* so the request can never be satisfied (a real contract bug). HIGH.
|
|
7
|
+
* • `request-without-example` — a request body with no curated `examples`/`example` (author one in `.meta`, or promote
|
|
8
|
+
* a tester `@public` row). A docs/demo readiness gap. LOW.
|
|
9
|
+
*
|
|
10
|
+
* BDD COVERAGE is the OTHER readiness gap — but it needs the `.feature` files, so `@suluk/journeys` computes it and the
|
|
11
|
+
* caller folds its letter in via `combineGrades` (harden never depends on journeys — the established harden+agents seam).
|
|
12
|
+
*/
|
|
13
|
+
import type { OpenAPIv4Document } from "@suluk/core";
|
|
14
|
+
import { fieldOrigin, type JsonSchema } from "@suluk/examples";
|
|
15
|
+
import { grade, type Finding, type Grade } from "./audit";
|
|
16
|
+
|
|
17
|
+
export interface ReadinessAudit {
|
|
18
|
+
findings: Finding[];
|
|
19
|
+
nodes: number;
|
|
20
|
+
clean: number;
|
|
21
|
+
score: number;
|
|
22
|
+
grade: Grade;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ReadinessOptions {
|
|
26
|
+
/** skip operations (e.g. third-party/ingested surfaces) — they don't count toward the readiness grade. */
|
|
27
|
+
ignore?: (uri: string, name: string) => boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface RawReq {
|
|
31
|
+
method: string;
|
|
32
|
+
contentSchema?: unknown;
|
|
33
|
+
parameterSchema?: { body?: unknown };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const hasExample = (s: JsonSchema): boolean => (Array.isArray(s.examples) && s.examples.length > 0) || "example" in s;
|
|
37
|
+
|
|
38
|
+
/** Audit the document's request bodies for client-sendability + example presence → findings + a readiness grade. */
|
|
39
|
+
export function auditReadiness(doc: OpenAPIv4Document, opts: ReadinessOptions = {}): ReadinessAudit {
|
|
40
|
+
const findings: Finding[] = [];
|
|
41
|
+
let nodes = 0;
|
|
42
|
+
let clean = 0;
|
|
43
|
+
const pass = () => {
|
|
44
|
+
nodes++;
|
|
45
|
+
clean++;
|
|
46
|
+
};
|
|
47
|
+
const fail = (f: Finding) => {
|
|
48
|
+
nodes++;
|
|
49
|
+
findings.push(f);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
for (const [uri, piRaw] of Object.entries(doc.paths ?? {})) {
|
|
53
|
+
const pi = piRaw as { requests?: Record<string, RawReq> };
|
|
54
|
+
for (const [name, req] of Object.entries(pi.requests ?? {})) {
|
|
55
|
+
if (opts.ignore?.(uri, name)) continue;
|
|
56
|
+
const body = (req.contentSchema ?? req.parameterSchema?.body) as JsonSchema | undefined;
|
|
57
|
+
if (!body || typeof body !== "object") continue; // no request body → nothing to assess
|
|
58
|
+
|
|
59
|
+
// node: a curated example present?
|
|
60
|
+
if (hasExample(body)) pass();
|
|
61
|
+
else fail({ rule: "request-without-example", severity: "low", path: `${name}/body`, message: `request '${name}' has no example`, fix: "author one in .meta({ examples }), or promote a tester @public row via `journeys promote`" });
|
|
62
|
+
|
|
63
|
+
// node per REQUIRED field: is it client-sendable (not computed/readOnly)?
|
|
64
|
+
const props = (body.properties ?? {}) as Record<string, JsonSchema>;
|
|
65
|
+
const required = new Set(Array.isArray(body.required) ? (body.required as string[]) : []);
|
|
66
|
+
for (const [k, sub] of Object.entries(props)) {
|
|
67
|
+
if (!required.has(k)) continue;
|
|
68
|
+
if (fieldOrigin(sub) === "computed") {
|
|
69
|
+
fail({ rule: "computed-required", severity: "high", path: `${name}/body/${k}`, message: `required field '${k}' is computed/readOnly — a client cannot send it`, fix: "make it optional or non-computed (drop x-suluk-origin:computed / readOnly), or remove it from required" });
|
|
70
|
+
} else {
|
|
71
|
+
pass();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const score = nodes === 0 ? 100 : Math.round((clean / nodes) * 100);
|
|
78
|
+
return { findings, nodes, clean, score, grade: grade(score) };
|
|
79
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { auditReadiness } from "../src/index";
|
|
3
|
+
import type { OpenAPIv4Document } from "@suluk/core";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* C043 — the readiness dimension: computed-required (a required field a client can't send) + request-without-example.
|
|
7
|
+
* Separate from the security grade; folded into combineGrades alongside security + journeys coverage.
|
|
8
|
+
*/
|
|
9
|
+
const doc = {
|
|
10
|
+
openapi: "4.0.0-candidate",
|
|
11
|
+
info: { title: "Billing" },
|
|
12
|
+
paths: {
|
|
13
|
+
"/charge": {
|
|
14
|
+
requests: {
|
|
15
|
+
charge: {
|
|
16
|
+
method: "post",
|
|
17
|
+
contentSchema: {
|
|
18
|
+
type: "object",
|
|
19
|
+
required: ["amountCents", "balance"],
|
|
20
|
+
properties: {
|
|
21
|
+
amountCents: { type: "integer" },
|
|
22
|
+
balance: { type: "integer", "x-suluk-origin": "computed" }, // required AND computed → a bug
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
"/good": {
|
|
29
|
+
requests: {
|
|
30
|
+
good: {
|
|
31
|
+
method: "post",
|
|
32
|
+
contentSchema: {
|
|
33
|
+
type: "object",
|
|
34
|
+
required: ["name"],
|
|
35
|
+
properties: { name: { type: "string" } },
|
|
36
|
+
examples: [{ name: "ok" }], // has an example
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
"/health": { requests: { health: { method: "get", responses: { "200": { status: 200 } } } } }, // no body → not assessed
|
|
42
|
+
},
|
|
43
|
+
} as unknown as OpenAPIv4Document;
|
|
44
|
+
|
|
45
|
+
describe("auditReadiness", () => {
|
|
46
|
+
const audit = auditReadiness(doc);
|
|
47
|
+
const rules = audit.findings.map((f) => f.rule);
|
|
48
|
+
|
|
49
|
+
test("flags a required computed/readOnly field as not client-sendable (high)", () => {
|
|
50
|
+
const f = audit.findings.find((x) => x.rule === "computed-required");
|
|
51
|
+
expect(f).toBeDefined();
|
|
52
|
+
expect(f!.severity).toBe("high");
|
|
53
|
+
expect(f!.path).toBe("charge/body/balance");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("flags a request body with no example (low)", () => {
|
|
57
|
+
const f = audit.findings.find((x) => x.rule === "request-without-example" && x.path === "charge/body");
|
|
58
|
+
expect(f).toBeDefined();
|
|
59
|
+
expect(f!.severity).toBe("low");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("a body WITH an example + only sendable required fields raises no finding", () => {
|
|
63
|
+
expect(rules).not.toContain("good/body");
|
|
64
|
+
expect(audit.findings.some((f) => f.path.startsWith("good/"))).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("a body-less op is not assessed (no nodes for it)", () => {
|
|
68
|
+
expect(audit.findings.some((f) => f.path.startsWith("health/"))).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("produces a score + grade from the clean/nodes ratio", () => {
|
|
72
|
+
expect(audit.nodes).toBeGreaterThan(0);
|
|
73
|
+
expect(["A", "B", "C", "D", "F"]).toContain(audit.grade);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("readOnly is treated as computed (no x-suluk-origin needed)", () => {
|
|
77
|
+
const ro = { openapi: "4.0.0-candidate", info: { title: "T" }, paths: { "/x": { requests: { x: { method: "post", contentSchema: { type: "object", required: ["id"], properties: { id: { type: "string", readOnly: true } } } } } } } } as unknown as OpenAPIv4Document;
|
|
78
|
+
expect(auditReadiness(ro).findings.some((f) => f.rule === "computed-required")).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("a clean contract grades A", () => {
|
|
82
|
+
const clean = { openapi: "4.0.0-candidate", info: { title: "T" }, paths: { "/x": { requests: { x: { method: "post", contentSchema: { type: "object", required: ["n"], properties: { n: { type: "string" } }, examples: [{ n: "a" }] } } } } } } as unknown as OpenAPIv4Document;
|
|
83
|
+
expect(auditReadiness(clean).grade).toBe("A");
|
|
84
|
+
});
|
|
85
|
+
});
|