@openwop/openwop-conformance 2.31.1 → 2.33.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +5 -4
  3. package/coverage.md +2 -1
  4. package/dist/cli.js +7 -4
  5. package/dist/lib/certification-bundle-v3.js +12 -2
  6. package/dist/lib/child-env.js +29 -0
  7. package/dist/lib/v2-profiles.js +66 -0
  8. package/dist/spec-artifacts.lock.json +2 -2
  9. package/package.json +2 -2
  10. package/requirements.json +130 -12
  11. package/scenario-majors.json +5 -2
  12. package/schemas/CORPUS-STAMP.json +17 -17
  13. package/src/cli.ts +7 -4
  14. package/src/lib/a2a-fake-peer.ts +16 -3
  15. package/src/lib/certification-bundle-v3.ts +11 -2
  16. package/src/lib/child-env.ts +29 -0
  17. package/src/lib/mcp-fake-server.ts +13 -0
  18. package/src/lib/v2-profiles.ts +66 -0
  19. package/src/lib/webhook-receiver.ts +24 -5
  20. package/src/scenarios/a2a-1-0-agent-card.test.ts +1 -1
  21. package/src/scenarios/a2a-peer-authority.test.ts +1 -1
  22. package/src/scenarios/a2a-task-roundtrip.test.ts +1 -1
  23. package/src/scenarios/a2a-version-negotiation.test.ts +3 -3
  24. package/src/scenarios/mcp-extension-opacity.test.ts +1 -1
  25. package/src/scenarios/mcp-mrtr-roundtrip.test.ts +1 -1
  26. package/src/scenarios/mcp-tool-roundtrip.test.ts +1 -1
  27. package/src/scenarios/mcp-version-negotiation.test.ts +3 -3
  28. package/src/scenarios/v2-durability-recovery.test.ts +245 -55
  29. package/src/scenarios/v2-minimum-version-refused.test.ts +2 -2
  30. package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +1 -1
  31. package/src/scenarios/v2-negotiation-authenticated.test.ts +2 -2
  32. package/src/scenarios/v2-negotiation-decided-emitted.test.ts +3 -3
  33. package/src/scenarios/v2-relaxation-recorded.test.ts +37 -2
  34. package/src/scenarios/v2-webhook-egress-refusal.test.ts +115 -0
@@ -0,0 +1,115 @@
1
+ /**
2
+ * v2-webhook-egress-refusal — the webhook egress guard REFUSES, at major 2.
3
+ *
4
+ * `spec/v2/core/webhooks.md` §SSRF: "At registration a host MUST reject (`400
5
+ * webhook_url_rejected`) non-`https://` URLs, RFC 1918 and loopback and
6
+ * link-local ranges, IPv6 ULA, cloud metadata hosts, and `localhost`."
7
+ *
8
+ * ── Why this file exists ─────────────────────────────────────────────────────
9
+ * Until 2.33.0 NO major-2 row asserted that sentence. The scenarios that do
10
+ * (`http-client-ssrf`, `webhook-negative`) are registered at major 1 only, and
11
+ * the three major-2 files that name `webhook_url_rejected` name it only as the
12
+ * soft-skip taken when a host refuses the suite's own receiver. So a v2 bundle
13
+ * could certify `openwop-core-standard` — the profile built on `webhooks` — on a
14
+ * host whose guard refused nothing at all.
15
+ *
16
+ * It was found the expensive way: the steward's own reference host had been
17
+ * cutting its certified bundles with the guard open (`OPENWOP_WEBHOOK_ALLOW_PRIVATE`,
18
+ * because every suite fixture was a loopback listener) and recording no
19
+ * relaxation, which `security-defaults.md` §Relaxations forbids. Nothing in the
20
+ * suite could see it. `conformance/README.md` even said so: "the suite cannot
21
+ * detect an undeclared one." For THIS relaxation that was never true — a host
22
+ * that accepts `https://127.0.0.1/…` has shown on the wire that its guard is open.
23
+ *
24
+ * ── The three outcomes ───────────────────────────────────────────────────────
25
+ * every probe refused → executed-pass. The guard holds.
26
+ * a probe ACCEPTED, relaxation
27
+ * DECLARED (OPENWOP_HOST_RELAXATIONS
28
+ * names a `webhooks.*` obligation) → inapplicable, with the declaration
29
+ * quoted. The bundle carries
30
+ * `host.relaxations[]` and the owning
31
+ * profile is denied by that (RFC 0173 §A.2).
32
+ * a probe ACCEPTED, nothing declared → executed-FAIL — and the emitter and
33
+ * verifier deny every profile that owns
34
+ * `webhooks` (`lib/v2-profiles.ts`
35
+ * `profilesDeniedByObservedRelaxation`).
36
+ *
37
+ * Registration-time only, by design: every probe is a destination the suite
38
+ * does not own, so nothing is ever delivered and an accepted registration is
39
+ * deleted at once. Delivery-time re-resolution is a separate MUST that needs a
40
+ * resolver the suite controls; it is not claimed here.
41
+ *
42
+ * @see spec/v2/core/webhooks.md §SSRF
43
+ * @see spec/v2/core/security-defaults.md §Relaxations
44
+ */
45
+
46
+ import { describe, expect, it } from 'vitest';
47
+ import { driver } from '../lib/driver.js';
48
+ import { gateFamily, v2Discovery } from '../lib/v2.js';
49
+ import { readErrorCode } from '../lib/error-envelope.js';
50
+ import { softSkip } from '../lib/soft-skip.js';
51
+ import { req } from '../lib/requirement-ids.js';
52
+
53
+ const ID = 'openwop.requirement.0171.webhook-egress-refused';
54
+ const DOC = 'spec/v2/core/webhooks.md §SSRF';
55
+ const EVENTS = ['run.completed'];
56
+
57
+ /** One destination per class the sentence names. None is ever contacted. */
58
+ const PROBES: ReadonlyArray<{ readonly url: string; readonly cls: string }> = [
59
+ { url: 'http://webhook-egress-probe.example.com/hook', cls: 'non-https scheme' },
60
+ { url: 'https://127.0.0.1/openwop-egress-probe', cls: 'loopback (IPv4)' },
61
+ { url: 'https://[::1]/openwop-egress-probe', cls: 'loopback (IPv6)' },
62
+ { url: 'https://localhost/openwop-egress-probe', cls: 'localhost' },
63
+ { url: 'https://10.255.255.1/openwop-egress-probe', cls: 'RFC 1918 (10/8)' },
64
+ { url: 'https://192.168.255.1/openwop-egress-probe', cls: 'RFC 1918 (192.168/16)' },
65
+ { url: 'https://169.254.169.254/latest/meta-data/', cls: 'link-local / cloud metadata' },
66
+ { url: 'https://[fd00::1]/openwop-egress-probe', cls: 'IPv6 ULA' },
67
+ ];
68
+
69
+ /** The `webhooks.*` obligations the operator declared relaxed, if any. */
70
+ function declaredWebhookRelaxations(): string[] {
71
+ const raw = process.env['OPENWOP_HOST_RELAXATIONS'];
72
+ if (!raw) return [];
73
+ try {
74
+ const parsed = JSON.parse(raw) as Array<{ obligation?: unknown }>;
75
+ return (Array.isArray(parsed) ? parsed : []).map((r) => String(r.obligation ?? '')).filter((o) => o.split('.')[0] === 'webhooks');
76
+ } catch {
77
+ return []; // the emitter rejects unparseable JSON loudly; this file does not second-guess it
78
+ }
79
+ }
80
+
81
+ describe('v2-webhook-egress-refusal (webhooks.md §SSRF — the guard refuses, at major 2)', () => {
82
+ it('a webhook registration naming a destination the guard MUST refuse is refused 400 webhook_url_rejected', async () => {
83
+ const doc = await v2Discovery();
84
+ if (!doc) return softSkip('blocked', 'v2 discovery unreachable');
85
+ if (!(await gateFamily('webhooks'))) return softSkip('inapplicable', 'webhooks family not advertised (gate recorded under openwop.family.webhooks)');
86
+
87
+ const accepted: string[] = [];
88
+ const wrongCode: string[] = [];
89
+ for (const probe of PROBES) {
90
+ const reg = await driver.post('/webhooks', { url: probe.url, events: EVENTS });
91
+ if (reg.status === 201) {
92
+ accepted.push(`${probe.cls} (${probe.url})`);
93
+ const webhookId = (reg.json as { webhookId?: unknown } | null)?.webhookId;
94
+ if (typeof webhookId === 'string') await driver.delete(`/webhooks/${encodeURIComponent(webhookId)}`);
95
+ } else if (!(reg.status === 400 && readErrorCode(reg.json) === 'webhook_url_rejected')) {
96
+ wrongCode.push(`${probe.cls}: ${reg.status} ${String(readErrorCode(reg.json))}`);
97
+ }
98
+ }
99
+
100
+ if (accepted.length > 0) {
101
+ const declared = declaredWebhookRelaxations();
102
+ if (declared.length > 0) {
103
+ return softSkip('inapplicable', `the egress guard is relaxed and the operator DECLARED it (OPENWOP_HOST_RELAXATIONS: ${declared.join(', ')}) — the host accepted ${accepted.length} of ${PROBES.length} destinations the guard MUST refuse (${accepted.join('; ')}). The refusal cannot be witnessed on a host running relaxed; the bundle carries host.relaxations[] and the profile that owns \`webhooks\` does not certify (security-defaults.md §Relaxations)`);
104
+ }
105
+ }
106
+ expect(
107
+ accepted,
108
+ req(ID, DOC, `a host MUST reject (400 webhook_url_rejected) non-https URLs, RFC 1918, loopback, link-local, IPv6 ULA, cloud-metadata hosts and localhost at registration — this host ACCEPTED ${accepted.length} of ${PROBES.length} (${accepted.join('; ')}) and no \`webhooks.*\` relaxation is declared in OPENWOP_HOST_RELAXATIONS. An open guard nobody declared is an UNDECLARED RELAXATION: security-defaults.md §Relaxations requires it in host.relaxations[], and no profile built on \`webhooks\` certifies either way`),
109
+ ).toEqual([]);
110
+ expect(
111
+ wrongCode,
112
+ req(ID, DOC, `the refusal MUST be 400 webhook_url_rejected (spec/v2/errors.json) — other answers: ${wrongCode.join('; ')}`),
113
+ ).toEqual([]);
114
+ }, 60_000);
115
+ });