@openwop/openwop-conformance 1.139.0 → 1.141.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/README.md +2 -0
- package/dist/lib/scenario-disposition.js +14 -1
- package/dist/lib/spec-coherence.js +103 -0
- package/package.json +1 -1
- package/schemas/CORPUS-STAMP.json +2 -2
- package/src/lib/scenario-disposition.ts +16 -0
- package/src/lib/spec-coherence-registry.test.ts +100 -0
- package/src/lib/spec-coherence.ts +106 -0
- package/src/lib/webhook-receiver.test.ts +69 -1
- package/src/lib/webhook-receiver.ts +70 -0
- package/src/scenarios/webhook-signed-delivery.test.ts +134 -11
- package/src/setup.ts +1 -1
package/README.md
CHANGED
|
@@ -79,6 +79,8 @@ Run `npm run test` for normal CI cadence; `npm run test:strict` when claiming fu
|
|
|
79
79
|
| `OPENWOP_OTEL_COLLECTOR_GRPC_PORT=4317` | Bind the OTLP/gRPC collector on a specific port (default `4317`, OTLP/gRPC convention). The host MUST be configured with `OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:<port>` AND `OTEL_EXPORTER_OTLP_PROTOCOL=grpc`. |
|
|
80
80
|
| `OPENWOP_OTEL_COLLECTOR_PORT=14318` | Bind the OTel collector on a specific port (default `4318`). The host MUST be configured with `OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:<port>`. |
|
|
81
81
|
| `OPENWOP_WEBHOOK_ALLOW_PRIVATE=true` | Relaxes the webhook egress guard for the loopback test receiver used by `webhook-signed-delivery.test.ts`, `webhook-negative.test.ts`, and `replay-fanout-suppression.test.ts`. **The receiver is `http://127.0.0.1:{port}/`, which `webhooks.md` forbids three separate times, and the opt-in MUST relax all three to be witnessable:** (1) the **scheme** check — §"SSRF protection" bullet 1 rejects non-`https://`, and this gate fires FIRST on a host that validates scheme before address; (2) the **registration-time** address check — §"SSRF protection"; and (3) **delivery-time re-resolution** — §"Delivery-time egress validation (RFC 0093)". Gates 2 and 3 are independent MUSTs at different layers, so an opt-in reaching only one layer **cannot** produce a witness: delivery-only leaves registration returning `400 webhook_url_rejected`, registration-only leaves the dispatcher refusing to connect. A host whose opt-in reaches one layer is **not** non-conformant — it cannot witness these scenarios, which is a property of the test posture and not of its webhook signing. The scheme gate went unstated here until 2026-08-25; a tier-2 host validating scheme-then-address was blocked by a gate the documented contract never named. Relaxing gates 2 and 3 is **test-only posture** — see `SECURITY/threat-model-secret-leakage.md` §4.9 for why a registration-time relaxation is the more dangerous of the two. When the host rejects, the scenario records `blocked` (RFC 0148 §A), not a pass. |
|
|
82
|
+
| `OPENWOP_WEBHOOK_RECEIVER_URL=<https-url>` | **The route to a webhook witness that relaxes nothing.** A host refusing the loopback receiver on *two* independent grounds — private address **and** non-`https:` — cannot be unblocked by `OPENWOP_WEBHOOK_ALLOW_PRIVATE`, because the scheme arm still stands. This supplies **the public `https:` front for THIS SUITE'S OWN receiver** (tunnel / TLS-terminating proxy), never an arbitrary endpoint: the scenario asserts on what *this process* received, so pointing registration elsewhere makes every header assertion vacuous while the row turns green. Zero deliveries with it set is a **hard failure**, never a skip. Preferred over the flag — it waives nothing and writes no durable subscription row aimed at a private address (`SECURITY/threat-model-secret-leakage.md` §4.9). Pair with `OPENWOP_WEBHOOK_RECEIVER_PORT`. |
|
|
83
|
+
| `OPENWOP_WEBHOOK_RECEIVER_PORT=<port>` | Pins the in-process webhook receiver to a known port instead of an ephemeral one. Required in practice by `OPENWOP_WEBHOOK_RECEIVER_URL`: a tunnel must be aimed at a port known **in advance**, and the receiver binds `0` by default. Unset ⇒ ephemeral, as before. |
|
|
82
84
|
| `OPENWOP_MCP_FAKE_SERVER=true` | Boots the synthetic MCP peer for `mcp-tool-roundtrip.test.ts`. |
|
|
83
85
|
| `OPENWOP_MCP_REAL_SERVER_URL=<base-url>` | Points the MCP wire-shape probe at a real MCP server. The probe POSTs JSON-RPC and reads a single-JSON response — matches MCP's `streamable-http` transport in single-response mode. **Does NOT support** stdio transport (which is what most `modelcontextprotocol/servers` references default to) or SSE-streamed responses; an operator collecting interop evidence today runs a custom `StreamableHTTPServerTransport`-style server that returns a single JSON body per request. Adding SSE-frame parsing is tracked in `docs/PROTOCOL-GAP-CLOSURE-PLAN.md` Track 6. Assertions relax to shape-only. When both this and `OPENWOP_MCP_FAKE_SERVER` are set, the real URL wins. Phase 3 T3.4 interop-evidence path. |
|
|
84
86
|
| `OPENWOP_A2A_FAKE_PEER=true` | Boots the synthetic A2A peer for `a2a-task-roundtrip.test.ts`. |
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
import { PROFILE_FLOOR_SCENARIOS } from './profiles.js';
|
|
29
29
|
import { requirementIdForScenario, requirementIdForPrefix, requirementsFor } from './requirement-registry.js';
|
|
30
30
|
import { UNCLASSIFIED_RETURN_DETAIL } from './soft-skip.js';
|
|
31
|
+
import { SPEC_COHERENCE_SCENARIOS, SPEC_COHERENCE_DETAIL } from './spec-coherence.js';
|
|
31
32
|
import { CERTIFIABLE } from './requirement-ledger.js';
|
|
32
33
|
/** All scenario basenames that appear in some profile's runtime floor. */
|
|
33
34
|
export function floorScenarioFiles() {
|
|
@@ -100,7 +101,19 @@ export function fileDisposition(states, gateReason, assertionCount) {
|
|
|
100
101
|
* - every test `ctx.skip()`ped ⇒ the file's noted reason if it wrote one
|
|
101
102
|
* BEFORE skipping (`ctx.skip()` throws), else `blocked` + the marker.
|
|
102
103
|
*/
|
|
103
|
-
export function resolveFileRecord(states, gateReason, assertionCount, noted) {
|
|
104
|
+
export function resolveFileRecord(states, gateReason, assertionCount, noted, specCoherenceFile) {
|
|
105
|
+
// A scenario whose subject is the CORPUS, skipped because the published
|
|
106
|
+
// tarball does not bundle spec/v1/. RFC 0148 §A: `blocked` is defined over
|
|
107
|
+
// ADVERTISED BEHAVIOUR, and there is none here — nothing about the host was
|
|
108
|
+
// ever going to be exercised. `inapplicable` is the honest label, and it is
|
|
109
|
+
// CERTIFIABLE, so these rows stop counting against a host that cannot affect
|
|
110
|
+
// them. See lib/spec-coherence.ts for why not a new disposition value.
|
|
111
|
+
if (specCoherenceFile !== undefined
|
|
112
|
+
&& SPEC_COHERENCE_SCENARIOS.has(specCoherenceFile)
|
|
113
|
+
&& !states.includes('fail')
|
|
114
|
+
&& assertionCount === 0) {
|
|
115
|
+
return { disposition: 'inapplicable', detail: SPEC_COHERENCE_DETAIL };
|
|
116
|
+
}
|
|
104
117
|
let { disposition, detail } = fileDisposition(states, gateReason, assertionCount);
|
|
105
118
|
if (disposition === 'executed-pass' && assertionCount === 0) {
|
|
106
119
|
if (noted !== null) {
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scenarios that measure the SPEC, not the host (RFC 0148 §A).
|
|
3
|
+
*
|
|
4
|
+
* ## The defect
|
|
5
|
+
*
|
|
6
|
+
* 28 scenarios read `spec/v1/*.md` to check the corpus is internally coherent —
|
|
7
|
+
* that the `protocolVersion` grammar in the schema matches RFC 0149, that error
|
|
8
|
+
* envelopes are the shape `error-envelope.schema.json` declares, that every
|
|
9
|
+
* normative example extracts and validates. They assert nothing whatever about
|
|
10
|
+
* a host.
|
|
11
|
+
*
|
|
12
|
+
* `spec/v1/` is deliberately NOT bundled in the published tarball (`paths.ts`
|
|
13
|
+
* says so), so in a published-layout run `V1_DIR` is null and they
|
|
14
|
+
* `describe.skipIf` at COLLECTION time. No test body runs, so no `softSkip`
|
|
15
|
+
* note is recorded, and `resolveFileRecord` resolves an all-skipped file with no
|
|
16
|
+
* reason to **`blocked`** carrying "every test returned early … no recorded
|
|
17
|
+
* reason".
|
|
18
|
+
*
|
|
19
|
+
* That row then lands in a HOST's certification bundle. A host operator reads
|
|
20
|
+
* `blocked` and cannot tell it from a real gap in their implementation. A
|
|
21
|
+
* tier-2 host measured 13 such rows — **a third of their undiagnosed set** —
|
|
22
|
+
* and only discovered what they were by pointing `OPENWOP_CONFORMANCE_ROOT` at
|
|
23
|
+
* a spec checkout and watching 85 assertions pass in about a second, 59 of them
|
|
24
|
+
* against a dead `localhost:9`.
|
|
25
|
+
*
|
|
26
|
+
* ## Why `inapplicable`, and why not the other four
|
|
27
|
+
*
|
|
28
|
+
* RFC 0148 §A defines the two candidates precisely, and the definitions decide
|
|
29
|
+
* it:
|
|
30
|
+
*
|
|
31
|
+
* - `blocked` — "**advertised behavior** could not be exercised because a
|
|
32
|
+
* required seam, fixture, credential, or dependency was unavailable."
|
|
33
|
+
* There is no advertised behaviour here. Nothing about the host was ever
|
|
34
|
+
* going to be exercised, so nothing about the host failed to be.
|
|
35
|
+
* - `inapplicable` — "the requirement does not apply to the captured
|
|
36
|
+
* discovery/profile set." A requirement about the spec corpus does not
|
|
37
|
+
* apply to any host's discovery set. This is the honest label.
|
|
38
|
+
*
|
|
39
|
+
* `executed-pass` is wrong for the obvious reason: in a run where the corpus is
|
|
40
|
+
* absent, nothing executed, and claiming a pass for an unrun scenario is the
|
|
41
|
+
* defect this whole disposition system exists to prevent. A NEW disposition
|
|
42
|
+
* value was considered and rejected — `certification-bundle-v2.schema.json`
|
|
43
|
+
* enumerates the five, and `verifyBundleV2` is a published consumer contract,
|
|
44
|
+
* so a sixth is a wire break for every existing verifier. Correct use of an
|
|
45
|
+
* existing value costs nothing and breaks no one.
|
|
46
|
+
*
|
|
47
|
+
* `inapplicable` is in `CERTIFIABLE`, which is the point: these rows stop
|
|
48
|
+
* counting against a host that has no way to affect them.
|
|
49
|
+
*
|
|
50
|
+
* ## Why a list and not a predicate
|
|
51
|
+
*
|
|
52
|
+
* The property is static — "gates on `V1_DIR` and never touches `driver`" — and
|
|
53
|
+
* cannot be evaluated from `setup.ts` at runtime. So it is a list, and a list
|
|
54
|
+
* drifts. `spec-coherence-registry.test.ts` re-derives it from source on every
|
|
55
|
+
* run and fails when the two disagree, which is the only thing that makes a
|
|
56
|
+
* hand-maintained set trustworthy.
|
|
57
|
+
*
|
|
58
|
+
* ## What is deliberately NOT here
|
|
59
|
+
*
|
|
60
|
+
* Seven scenarios gate on `V1_DIR` **and** drive the host
|
|
61
|
+
* (`replay-side-effect-suppression`, `data-residency-admission`,
|
|
62
|
+
* `profile-discovery-core-alias`, `workflow-variable-format`,
|
|
63
|
+
* `workflow-chain-deferred-parameters`, `artifact-type-store-emission`,
|
|
64
|
+
* `artifact-type-registration-source`). Those assert advertised host behaviour
|
|
65
|
+
* that could not be exercised because a dependency was unavailable — which is
|
|
66
|
+
* `blocked`, exactly as §A defines it. Classifying them `inapplicable` would
|
|
67
|
+
* tell a host "this does not apply to you" about a requirement that does.
|
|
68
|
+
*/
|
|
69
|
+
/** Scenarios whose subject is the corpus. Kept honest by `spec-coherence-registry.test.ts`. */
|
|
70
|
+
export const SPEC_COHERENCE_SCENARIOS = new Set([
|
|
71
|
+
'artifact-schema-compile-bounded.test.ts',
|
|
72
|
+
'artifact-type-legacy-ids.test.ts',
|
|
73
|
+
'capability-example-root-layout.test.ts',
|
|
74
|
+
'certification-floor-enforcement.test.ts',
|
|
75
|
+
'chain-subchain-unsupported-refused.test.ts',
|
|
76
|
+
'compensation-profile.test.ts',
|
|
77
|
+
'core-manifest-and-extension-registry.test.ts',
|
|
78
|
+
'discovery-canonical-family-no-shadow.test.ts',
|
|
79
|
+
'edge-condition-truthy-falsy.test.ts',
|
|
80
|
+
'effect-identity-composition.test.ts',
|
|
81
|
+
'effect-identity-cross-scope.test.ts',
|
|
82
|
+
'error-envelope-canonical-shape.test.ts',
|
|
83
|
+
'form-content-packs.test.ts',
|
|
84
|
+
'multi-region-effect-vocabulary.test.ts',
|
|
85
|
+
'normative-example-extraction.test.ts',
|
|
86
|
+
'openapi-asyncapi-sdk-parity.test.ts',
|
|
87
|
+
'pack-manifest-extensions.test.ts',
|
|
88
|
+
'protocol-version-grammar.test.ts',
|
|
89
|
+
'registry-declarative-kinds.test.ts',
|
|
90
|
+
'rfc-0147-self-audit.test.ts',
|
|
91
|
+
'rfc-lifecycle-coherence.test.ts',
|
|
92
|
+
'semantic-digest-v2.test.ts',
|
|
93
|
+
'spec-corpus-validity.test.ts',
|
|
94
|
+
'spec-section-citations.test.ts',
|
|
95
|
+
'tool-result-trust-monotone.test.ts',
|
|
96
|
+
'versioned-composition-profiles.test.ts',
|
|
97
|
+
'workflow-chain-internal-flag.test.ts',
|
|
98
|
+
'workload-identity-profile.test.ts',
|
|
99
|
+
]);
|
|
100
|
+
/** The reason recorded on such a row, written for the host operator reading it. */
|
|
101
|
+
export const SPEC_COHERENCE_DETAIL = 'inapplicable to any host: this scenario reads spec/v1/ to check the SPEC corpus is internally coherent and asserts nothing about a host. '
|
|
102
|
+
+ 'The published tarball does not bundle spec/v1/ (see lib/paths.ts), so it does not run here. '
|
|
103
|
+
+ 'Set OPENWOP_CONFORMANCE_ROOT to a spec checkout to run it; it needs no host.';
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_comment": "Provenance of this vendored schemas/ copy. See conformance/README.md \u00a7\"Resolving the contract\". Compare against the stamp in your installed @openwop/openwop-conformance to detect a stale hand-copied contract.",
|
|
3
|
-
"suiteVersion": "1.
|
|
4
|
-
"corpusCommit": "
|
|
3
|
+
"suiteVersion": "1.141.0",
|
|
4
|
+
"corpusCommit": "de70377b76d34fdec3b1f4b7e27b8ade95a9ba27"
|
|
5
5
|
}
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
import { PROFILE_FLOOR_SCENARIOS } from './profiles.js';
|
|
30
30
|
import { requirementIdForScenario, requirementIdForPrefix, requirementsFor } from './requirement-registry.js';
|
|
31
31
|
import { UNCLASSIFIED_RETURN_DETAIL } from './soft-skip.js';
|
|
32
|
+
import { SPEC_COHERENCE_SCENARIOS, SPEC_COHERENCE_DETAIL } from './spec-coherence.js';
|
|
32
33
|
import { CERTIFIABLE, type Disposition, type LedgerEntry } from './requirement-ledger.js';
|
|
33
34
|
|
|
34
35
|
/** All scenario basenames that appear in some profile's runtime floor. */
|
|
@@ -112,7 +113,22 @@ export function resolveFileRecord(
|
|
|
112
113
|
gateReason: 'inapplicable' | 'skipped' | undefined,
|
|
113
114
|
assertionCount: number,
|
|
114
115
|
noted: { kind: 'inapplicable' | 'skipped' | 'blocked'; reason: string } | null,
|
|
116
|
+
specCoherenceFile?: string,
|
|
115
117
|
): { disposition: Disposition; detail?: string } {
|
|
118
|
+
// A scenario whose subject is the CORPUS, skipped because the published
|
|
119
|
+
// tarball does not bundle spec/v1/. RFC 0148 §A: `blocked` is defined over
|
|
120
|
+
// ADVERTISED BEHAVIOUR, and there is none here — nothing about the host was
|
|
121
|
+
// ever going to be exercised. `inapplicable` is the honest label, and it is
|
|
122
|
+
// CERTIFIABLE, so these rows stop counting against a host that cannot affect
|
|
123
|
+
// them. See lib/spec-coherence.ts for why not a new disposition value.
|
|
124
|
+
if (
|
|
125
|
+
specCoherenceFile !== undefined
|
|
126
|
+
&& SPEC_COHERENCE_SCENARIOS.has(specCoherenceFile)
|
|
127
|
+
&& !states.includes('fail')
|
|
128
|
+
&& assertionCount === 0
|
|
129
|
+
) {
|
|
130
|
+
return { disposition: 'inapplicable', detail: SPEC_COHERENCE_DETAIL };
|
|
131
|
+
}
|
|
116
132
|
let { disposition, detail } = fileDisposition(states, gateReason, assertionCount);
|
|
117
133
|
if (disposition === 'executed-pass' && assertionCount === 0) {
|
|
118
134
|
if (noted !== null) {
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeps `SPEC_COHERENCE_SCENARIOS` honest by re-deriving it from source.
|
|
3
|
+
*
|
|
4
|
+
* A hand-maintained list of filenames is a claim that decays silently: a new
|
|
5
|
+
* spec-coherence scenario lands and reports `blocked` in every host's bundle
|
|
6
|
+
* forever, or one grows a `driver` call and starts telling hosts a requirement
|
|
7
|
+
* about their own behaviour does not apply to them. Neither shows up as a
|
|
8
|
+
* failure anywhere — which is the whole reason the original defect survived.
|
|
9
|
+
*
|
|
10
|
+
* The membership rule is mechanical, so the check can be too:
|
|
11
|
+
* gates on `V1_DIR === null` AND never calls `driver.get/post/delete`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, expect, it } from 'vitest';
|
|
15
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { SPEC_COHERENCE_SCENARIOS, SPEC_COHERENCE_DETAIL } from './spec-coherence.js';
|
|
18
|
+
import { resolveFileRecord } from './scenario-disposition.js';
|
|
19
|
+
|
|
20
|
+
const SCENARIOS = new URL('../scenarios/', import.meta.url).pathname;
|
|
21
|
+
|
|
22
|
+
function derive(): { pure: string[]; hostTouching: string[] } {
|
|
23
|
+
const pure: string[] = [];
|
|
24
|
+
const hostTouching: string[] = [];
|
|
25
|
+
for (const f of readdirSync(SCENARIOS)) {
|
|
26
|
+
if (!f.endsWith('.test.ts')) continue;
|
|
27
|
+
const src = readFileSync(join(SCENARIOS, f), 'utf8');
|
|
28
|
+
if (!/V1_DIR\s*===?\s*null/.test(src)) continue;
|
|
29
|
+
(/\bdriver\.(get|post|delete)\b/.test(src) ? hostTouching : pure).push(f);
|
|
30
|
+
}
|
|
31
|
+
return { pure: pure.sort(), hostTouching: hostTouching.sort() };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('SPEC_COHERENCE_SCENARIOS is derivable, not asserted', () => {
|
|
35
|
+
it('matches every scenario that reads spec/v1 and never drives a host', () => {
|
|
36
|
+
const { pure } = derive();
|
|
37
|
+
const listed = [...SPEC_COHERENCE_SCENARIOS].sort();
|
|
38
|
+
// Named diffs rather than a bare inequality: a failure here should say
|
|
39
|
+
// which file to add or drop, not that two sets differ.
|
|
40
|
+
expect(pure.filter((f) => !SPEC_COHERENCE_SCENARIOS.has(f)), 'reads spec/v1, drives no host, NOT in the registry — it will report `blocked` in every host bundle').toEqual([]);
|
|
41
|
+
expect(listed.filter((f) => !pure.includes(f)), 'in the registry but no longer qualifies — it now drives a host, or stopped reading spec/v1').toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('excludes the host-touching ones, which are honestly `blocked`', () => {
|
|
45
|
+
// These assert ADVERTISED behaviour that a missing dependency prevented
|
|
46
|
+
// exercising — RFC 0148 §A's definition of `blocked`, verbatim. Calling
|
|
47
|
+
// them `inapplicable` would tell a host a requirement about its own
|
|
48
|
+
// behaviour does not apply to it.
|
|
49
|
+
const { hostTouching } = derive();
|
|
50
|
+
expect(hostTouching.length, 'expected some V1_DIR-gated scenarios to also drive the host').toBeGreaterThan(0);
|
|
51
|
+
for (const f of hostTouching) {
|
|
52
|
+
expect(SPEC_COHERENCE_SCENARIOS.has(f), `${f} drives a host and must NOT be classified inapplicable`).toBe(false);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('the registry is non-empty — an empty set would silently disable the fix', () => {
|
|
57
|
+
expect(SPEC_COHERENCE_SCENARIOS.size).toBeGreaterThan(20);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('resolveFileRecord classifies a corpus scenario as inapplicable, not blocked', () => {
|
|
62
|
+
// A published-layout run: describe.skipIf fires at COLLECTION, so vitest
|
|
63
|
+
// reports the file's tests as skipped, nothing notes a reason, and before
|
|
64
|
+
// this change resolveFileRecord returned `blocked` with the unclassified
|
|
65
|
+
// marker — the row a host operator could not tell from a real gap.
|
|
66
|
+
const CORPUS = 'protocol-version-grammar.test.ts';
|
|
67
|
+
|
|
68
|
+
it('a corpus scenario that never ran is inapplicable, with a reason aimed at the host operator', () => {
|
|
69
|
+
const r = resolveFileRecord(['skip', 'skip'], undefined, 0, null, CORPUS);
|
|
70
|
+
expect(r.disposition).toBe('inapplicable');
|
|
71
|
+
expect(r.detail).toBe(SPEC_COHERENCE_DETAIL);
|
|
72
|
+
expect(r.detail).toContain('asserts nothing about a host');
|
|
73
|
+
expect(r.detail).toContain('OPENWOP_CONFORMANCE_ROOT');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('WITHOUT the registry it would still be blocked — the branch is what changes it', () => {
|
|
77
|
+
// Same inputs, filename withheld: the pre-change behaviour. This is the
|
|
78
|
+
// negative control; if it ever returns `inapplicable`, the branch is not
|
|
79
|
+
// what is doing the work and the test above proves nothing.
|
|
80
|
+
const r = resolveFileRecord(['skip', 'skip'], undefined, 0, null);
|
|
81
|
+
expect(r.disposition).toBe('blocked');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('a non-corpus scenario is untouched', () => {
|
|
85
|
+
const r = resolveFileRecord(['skip', 'skip'], undefined, 0, null, 'webhook-signed-delivery.test.ts');
|
|
86
|
+
expect(r.disposition).toBe('blocked');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('a corpus scenario that FAILED is never laundered into inapplicable', () => {
|
|
90
|
+
// The guard that matters: if the corpus IS present and an assertion fails,
|
|
91
|
+
// that is a real spec-coherence defect and must stay executed-fail.
|
|
92
|
+
const r = resolveFileRecord(['pass', 'fail'], undefined, 12, null, CORPUS);
|
|
93
|
+
expect(r.disposition).toBe('executed-fail');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('a corpus scenario that RAN and passed stays executed-pass', () => {
|
|
97
|
+
const r = resolveFileRecord(['pass'], undefined, 40, null, CORPUS);
|
|
98
|
+
expect(r.disposition).toBe('executed-pass');
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scenarios that measure the SPEC, not the host (RFC 0148 §A).
|
|
3
|
+
*
|
|
4
|
+
* ## The defect
|
|
5
|
+
*
|
|
6
|
+
* 28 scenarios read `spec/v1/*.md` to check the corpus is internally coherent —
|
|
7
|
+
* that the `protocolVersion` grammar in the schema matches RFC 0149, that error
|
|
8
|
+
* envelopes are the shape `error-envelope.schema.json` declares, that every
|
|
9
|
+
* normative example extracts and validates. They assert nothing whatever about
|
|
10
|
+
* a host.
|
|
11
|
+
*
|
|
12
|
+
* `spec/v1/` is deliberately NOT bundled in the published tarball (`paths.ts`
|
|
13
|
+
* says so), so in a published-layout run `V1_DIR` is null and they
|
|
14
|
+
* `describe.skipIf` at COLLECTION time. No test body runs, so no `softSkip`
|
|
15
|
+
* note is recorded, and `resolveFileRecord` resolves an all-skipped file with no
|
|
16
|
+
* reason to **`blocked`** carrying "every test returned early … no recorded
|
|
17
|
+
* reason".
|
|
18
|
+
*
|
|
19
|
+
* That row then lands in a HOST's certification bundle. A host operator reads
|
|
20
|
+
* `blocked` and cannot tell it from a real gap in their implementation. A
|
|
21
|
+
* tier-2 host measured 13 such rows — **a third of their undiagnosed set** —
|
|
22
|
+
* and only discovered what they were by pointing `OPENWOP_CONFORMANCE_ROOT` at
|
|
23
|
+
* a spec checkout and watching 85 assertions pass in about a second, 59 of them
|
|
24
|
+
* against a dead `localhost:9`.
|
|
25
|
+
*
|
|
26
|
+
* ## Why `inapplicable`, and why not the other four
|
|
27
|
+
*
|
|
28
|
+
* RFC 0148 §A defines the two candidates precisely, and the definitions decide
|
|
29
|
+
* it:
|
|
30
|
+
*
|
|
31
|
+
* - `blocked` — "**advertised behavior** could not be exercised because a
|
|
32
|
+
* required seam, fixture, credential, or dependency was unavailable."
|
|
33
|
+
* There is no advertised behaviour here. Nothing about the host was ever
|
|
34
|
+
* going to be exercised, so nothing about the host failed to be.
|
|
35
|
+
* - `inapplicable` — "the requirement does not apply to the captured
|
|
36
|
+
* discovery/profile set." A requirement about the spec corpus does not
|
|
37
|
+
* apply to any host's discovery set. This is the honest label.
|
|
38
|
+
*
|
|
39
|
+
* `executed-pass` is wrong for the obvious reason: in a run where the corpus is
|
|
40
|
+
* absent, nothing executed, and claiming a pass for an unrun scenario is the
|
|
41
|
+
* defect this whole disposition system exists to prevent. A NEW disposition
|
|
42
|
+
* value was considered and rejected — `certification-bundle-v2.schema.json`
|
|
43
|
+
* enumerates the five, and `verifyBundleV2` is a published consumer contract,
|
|
44
|
+
* so a sixth is a wire break for every existing verifier. Correct use of an
|
|
45
|
+
* existing value costs nothing and breaks no one.
|
|
46
|
+
*
|
|
47
|
+
* `inapplicable` is in `CERTIFIABLE`, which is the point: these rows stop
|
|
48
|
+
* counting against a host that has no way to affect them.
|
|
49
|
+
*
|
|
50
|
+
* ## Why a list and not a predicate
|
|
51
|
+
*
|
|
52
|
+
* The property is static — "gates on `V1_DIR` and never touches `driver`" — and
|
|
53
|
+
* cannot be evaluated from `setup.ts` at runtime. So it is a list, and a list
|
|
54
|
+
* drifts. `spec-coherence-registry.test.ts` re-derives it from source on every
|
|
55
|
+
* run and fails when the two disagree, which is the only thing that makes a
|
|
56
|
+
* hand-maintained set trustworthy.
|
|
57
|
+
*
|
|
58
|
+
* ## What is deliberately NOT here
|
|
59
|
+
*
|
|
60
|
+
* Seven scenarios gate on `V1_DIR` **and** drive the host
|
|
61
|
+
* (`replay-side-effect-suppression`, `data-residency-admission`,
|
|
62
|
+
* `profile-discovery-core-alias`, `workflow-variable-format`,
|
|
63
|
+
* `workflow-chain-deferred-parameters`, `artifact-type-store-emission`,
|
|
64
|
+
* `artifact-type-registration-source`). Those assert advertised host behaviour
|
|
65
|
+
* that could not be exercised because a dependency was unavailable — which is
|
|
66
|
+
* `blocked`, exactly as §A defines it. Classifying them `inapplicable` would
|
|
67
|
+
* tell a host "this does not apply to you" about a requirement that does.
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
/** Scenarios whose subject is the corpus. Kept honest by `spec-coherence-registry.test.ts`. */
|
|
71
|
+
export const SPEC_COHERENCE_SCENARIOS: ReadonlySet<string> = new Set([
|
|
72
|
+
'artifact-schema-compile-bounded.test.ts',
|
|
73
|
+
'artifact-type-legacy-ids.test.ts',
|
|
74
|
+
'capability-example-root-layout.test.ts',
|
|
75
|
+
'certification-floor-enforcement.test.ts',
|
|
76
|
+
'chain-subchain-unsupported-refused.test.ts',
|
|
77
|
+
'compensation-profile.test.ts',
|
|
78
|
+
'core-manifest-and-extension-registry.test.ts',
|
|
79
|
+
'discovery-canonical-family-no-shadow.test.ts',
|
|
80
|
+
'edge-condition-truthy-falsy.test.ts',
|
|
81
|
+
'effect-identity-composition.test.ts',
|
|
82
|
+
'effect-identity-cross-scope.test.ts',
|
|
83
|
+
'error-envelope-canonical-shape.test.ts',
|
|
84
|
+
'form-content-packs.test.ts',
|
|
85
|
+
'multi-region-effect-vocabulary.test.ts',
|
|
86
|
+
'normative-example-extraction.test.ts',
|
|
87
|
+
'openapi-asyncapi-sdk-parity.test.ts',
|
|
88
|
+
'pack-manifest-extensions.test.ts',
|
|
89
|
+
'protocol-version-grammar.test.ts',
|
|
90
|
+
'registry-declarative-kinds.test.ts',
|
|
91
|
+
'rfc-0147-self-audit.test.ts',
|
|
92
|
+
'rfc-lifecycle-coherence.test.ts',
|
|
93
|
+
'semantic-digest-v2.test.ts',
|
|
94
|
+
'spec-corpus-validity.test.ts',
|
|
95
|
+
'spec-section-citations.test.ts',
|
|
96
|
+
'tool-result-trust-monotone.test.ts',
|
|
97
|
+
'versioned-composition-profiles.test.ts',
|
|
98
|
+
'workflow-chain-internal-flag.test.ts',
|
|
99
|
+
'workload-identity-profile.test.ts',
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
/** The reason recorded on such a row, written for the host operator reading it. */
|
|
103
|
+
export const SPEC_COHERENCE_DETAIL =
|
|
104
|
+
'inapplicable to any host: this scenario reads spec/v1/ to check the SPEC corpus is internally coherent and asserts nothing about a host. '
|
|
105
|
+
+ 'The published tarball does not bundle spec/v1/ (see lib/paths.ts), so it does not run here. '
|
|
106
|
+
+ 'Set OPENWOP_CONFORMANCE_ROOT to a spec checkout to run it; it needs no host.';
|
|
@@ -12,13 +12,14 @@
|
|
|
12
12
|
* @see webhook-receiver.ts, spec/v1/webhooks.md §"Delivery headers"
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { describe, it, expect } from 'vitest';
|
|
15
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
16
16
|
import { createHmac } from 'node:crypto';
|
|
17
17
|
import {
|
|
18
18
|
SIGNATURE_PREFIX,
|
|
19
19
|
createReceiverState,
|
|
20
20
|
verifyWebhookDelivery,
|
|
21
21
|
signPayload,
|
|
22
|
+
resolveRegistrationUrl,
|
|
22
23
|
} from './webhook-receiver.js';
|
|
23
24
|
|
|
24
25
|
const SECRET = 'shhh-not-a-real-secret';
|
|
@@ -74,3 +75,70 @@ describe('webhook-receiver: the X-openwop-Signature prefix follows the spec', ()
|
|
|
74
75
|
expect(algorithmHeader).toBe('v1');
|
|
75
76
|
});
|
|
76
77
|
});
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* `OPENWOP_WEBHOOK_RECEIVER_URL` validation.
|
|
81
|
+
*
|
|
82
|
+
* Each case names the gate it is about, taken from `webhooks.md`, rather than
|
|
83
|
+
* restating the implementation: the point of the variable is to clear all three
|
|
84
|
+
* SSRF gates honestly, so a value that cannot clear one of them is an operator
|
|
85
|
+
* error the suite must refuse LOUDLY. A skip here would hide a
|
|
86
|
+
* misconfiguration behind a disposition that reads as "the host could not be
|
|
87
|
+
* exercised", which is a claim about the host and would be false.
|
|
88
|
+
*/
|
|
89
|
+
describe('resolveRegistrationUrl — OPENWOP_WEBHOOK_RECEIVER_URL', () => {
|
|
90
|
+
const LOCAL = 'http://127.0.0.1:54321/';
|
|
91
|
+
const saved = process.env.OPENWOP_WEBHOOK_RECEIVER_URL;
|
|
92
|
+
const set = (v: string | undefined) => {
|
|
93
|
+
if (v === undefined) delete process.env.OPENWOP_WEBHOOK_RECEIVER_URL;
|
|
94
|
+
else process.env.OPENWOP_WEBHOOK_RECEIVER_URL = v;
|
|
95
|
+
};
|
|
96
|
+
afterEach(() => set(saved));
|
|
97
|
+
|
|
98
|
+
it('unset ⇒ registers the local receiver unchanged, not tunnelled', () => {
|
|
99
|
+
set(undefined);
|
|
100
|
+
expect(resolveRegistrationUrl(LOCAL)).toEqual({ url: LOCAL, tunnelled: false });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('whitespace-only is treated as unset rather than as a malformed URL', () => {
|
|
104
|
+
set(' ');
|
|
105
|
+
expect(resolveRegistrationUrl(LOCAL)).toEqual({ url: LOCAL, tunnelled: false });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('a public https front is used for registration and marked tunnelled', () => {
|
|
109
|
+
set('https://tunnel.example.com/hook');
|
|
110
|
+
expect(resolveRegistrationUrl(LOCAL)).toEqual({
|
|
111
|
+
url: 'https://tunnel.example.com/hook',
|
|
112
|
+
tunnelled: true,
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('rejects http: — cannot clear gate 1 (webhooks.md §"Register": url MUST be https)', () => {
|
|
117
|
+
set('http://tunnel.example.com/hook');
|
|
118
|
+
expect(() => resolveRegistrationUrl(LOCAL)).toThrow(/MUST be https/i);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it.each([
|
|
122
|
+
['loopback name', 'https://localhost/hook'],
|
|
123
|
+
['loopback v4', 'https://127.0.0.1/hook'],
|
|
124
|
+
['RFC1918 10/8', 'https://10.1.2.3/hook'],
|
|
125
|
+
['RFC1918 192.168/16', 'https://192.168.1.9/hook'],
|
|
126
|
+
['RFC1918 172.16/12', 'https://172.20.0.5/hook'],
|
|
127
|
+
['link-local', 'https://169.254.169.254/hook'],
|
|
128
|
+
])('rejects %s — cannot clear gate 2 (registration-time address check)', (_label, url) => {
|
|
129
|
+
set(url);
|
|
130
|
+
expect(() => resolveRegistrationUrl(LOCAL)).toThrow(/publicly-resolvable/i);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('rejects a value that is not a URL at all', () => {
|
|
134
|
+
set('not a url');
|
|
135
|
+
expect(() => resolveRegistrationUrl(LOCAL)).toThrow(/not a valid URL/i);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('does not reject a public host that merely LOOKS private (172.32 is public)', () => {
|
|
139
|
+
// 172.16.0.0/12 ends at 172.31.255.255. A naive /^172\./ check would
|
|
140
|
+
// reject this and send an operator hunting a nonexistent misconfiguration.
|
|
141
|
+
set('https://172.32.0.1/hook');
|
|
142
|
+
expect(resolveRegistrationUrl(LOCAL).tunnelled).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -184,3 +184,73 @@ export async function discoverOwnedTenant(
|
|
|
184
184
|
// Pre-S29 hosts that copied the suite's misnamed field: tolerated, never asserted.
|
|
185
185
|
return typeof owner?.tenantId === 'string' && owner.tenantId.length > 0 ? owner.tenantId : undefined;
|
|
186
186
|
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The public `https:` front for the conformance webhook receiver, when the operator
|
|
190
|
+
* has wired one (`OPENWOP_WEBHOOK_RECEIVER_URL`).
|
|
191
|
+
*
|
|
192
|
+
* This does NOT point the host at some third-party endpoint. The delivery must
|
|
193
|
+
* still land on the local `startReceiver()` server, because every assertion in
|
|
194
|
+
* the scenario reads `receiver.received` — an IN-PROCESS array. Registering an
|
|
195
|
+
* arbitrary URL would send the delivery somewhere the suite cannot observe, and
|
|
196
|
+
* the row would turn green while every header and HMAC assertion went vacuous.
|
|
197
|
+
* That is the precise defect this suite exists to catch, so the variable is
|
|
198
|
+
* specified as "a tunnel or TLS-terminating proxy in front of THIS receiver",
|
|
199
|
+
* never "an endpoint of your choosing".
|
|
200
|
+
*
|
|
201
|
+
* The suite cannot verify that the tunnel actually fronts this process — that
|
|
202
|
+
* is the operator's contract. What it CAN do is refuse to let a mis-wired
|
|
203
|
+
* tunnel look like a pass: with the variable set, zero observed deliveries is a
|
|
204
|
+
* hard assertion failure, never a soft-skip (see the delivery assertion below).
|
|
205
|
+
*
|
|
206
|
+
* Validation is deliberately strict and fails LOUDLY rather than skipping: a
|
|
207
|
+
* malformed value is an operator error, and turning it into a `blocked` row
|
|
208
|
+
* would hide the misconfiguration behind a disposition that reads as
|
|
209
|
+
* "the host could not be exercised".
|
|
210
|
+
*/
|
|
211
|
+
export function resolveRegistrationUrl(localUrl: string): { url: string; tunnelled: boolean } {
|
|
212
|
+
const raw = process.env.OPENWOP_WEBHOOK_RECEIVER_URL?.trim();
|
|
213
|
+
if (!raw) return { url: localUrl, tunnelled: false };
|
|
214
|
+
|
|
215
|
+
let parsed: URL;
|
|
216
|
+
try {
|
|
217
|
+
parsed = new URL(raw);
|
|
218
|
+
} catch {
|
|
219
|
+
throw new Error(
|
|
220
|
+
`OPENWOP_WEBHOOK_RECEIVER_URL is not a valid URL: ${JSON.stringify(raw)}`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Must clear gate 1 (scheme). An `http:` front cannot satisfy a host that
|
|
225
|
+
// validates scheme before address, which is the ordering that made the
|
|
226
|
+
// ALLOW_PRIVATE flag insufficient in the first place.
|
|
227
|
+
if (parsed.protocol !== 'https:') {
|
|
228
|
+
throw new Error(
|
|
229
|
+
`OPENWOP_WEBHOOK_RECEIVER_URL MUST be https: (got ${parsed.protocol}). ` +
|
|
230
|
+
'A plain-http front cannot clear the scheme gate, so it cannot witness this scenario.',
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Must clear gate 2 (registration-time address check). A loopback or private
|
|
235
|
+
// hostname here is just the local URL wearing a different scheme — it would
|
|
236
|
+
// be rejected for the same reason, and the operator would read the resulting
|
|
237
|
+
// failure as a host defect rather than as their own misconfiguration.
|
|
238
|
+
const host = parsed.hostname.toLowerCase();
|
|
239
|
+
const isLoopback =
|
|
240
|
+
host === 'localhost' || host === '::1' || host.startsWith('127.');
|
|
241
|
+
const isPrivate =
|
|
242
|
+
/^10\./.test(host) ||
|
|
243
|
+
/^192\.168\./.test(host) ||
|
|
244
|
+
/^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
|
|
245
|
+
/^169\.254\./.test(host) ||
|
|
246
|
+
/^(fc|fd)/.test(host);
|
|
247
|
+
if (isLoopback || isPrivate) {
|
|
248
|
+
throw new Error(
|
|
249
|
+
`OPENWOP_WEBHOOK_RECEIVER_URL MUST be a publicly-resolvable host (got ${parsed.hostname}). ` +
|
|
250
|
+
'It is the PUBLIC front for the local receiver — a tunnel or TLS-terminating proxy — ' +
|
|
251
|
+
'not the receiver address itself.',
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return { url: raw, tunnelled: true };
|
|
256
|
+
}
|
|
@@ -63,6 +63,32 @@
|
|
|
63
63
|
* writes a durable subscription row that survives the flag being turned
|
|
64
64
|
* back off.
|
|
65
65
|
*
|
|
66
|
+
* `OPENWOP_WEBHOOK_RECEIVER_URL` — the route that relaxes NOTHING (added
|
|
67
|
+
* 2026-08-28). Set it to a public `https:` tunnel or TLS-terminating proxy
|
|
68
|
+
* standing in front of this scenario's own local receiver. Registration then
|
|
69
|
+
* uses that URL and all three gates are satisfied honestly: the scheme is
|
|
70
|
+
* `https:`, the registered address is public, and delivery-time re-resolution
|
|
71
|
+
* resolves a public address it is entitled to connect to. No guard is waived,
|
|
72
|
+
* no durable private-address row is written, and §4.9's hazard does not arise.
|
|
73
|
+
*
|
|
74
|
+
* This exists because the ALLOW_PRIVATE contract above is unsatisfiable for a
|
|
75
|
+
* host whose guard is strongest at registration time: reported by a tier-2
|
|
76
|
+
* host (2026-08-25) whose `classifyUrl` rejects on scheme AND address as two
|
|
77
|
+
* independent grounds returning one code, so relaxing the address check leaves
|
|
78
|
+
* the scheme check standing and the row `blocked` either way. Such a host is
|
|
79
|
+
* conformant and was simply unmeasurable. It is now measurable.
|
|
80
|
+
*
|
|
81
|
+
* Two things it deliberately does NOT do. It is not "register any endpoint":
|
|
82
|
+
* the assertions read an in-process array, so a third-party destination would
|
|
83
|
+
* turn the row green while every assertion went vacuous — see
|
|
84
|
+
* `resolveRegistrationUrl`. And it does not soften a rejection into a skip: a
|
|
85
|
+
* host that refuses a legitimate public https destination FAILS, because
|
|
86
|
+
* `blocked` would let it record a missing precondition forever instead of the
|
|
87
|
+
* finding it earned.
|
|
88
|
+
*
|
|
89
|
+
* Paired with a positive control (bottom of this file) so the tunnelled pass
|
|
90
|
+
* is attributable to a guard that works rather than to a guard that is absent.
|
|
91
|
+
*
|
|
66
92
|
* When the host rejects with `400 webhook_url_rejected`, this scenario
|
|
67
93
|
* records `blocked` (RFC 0148 §A) — NOT a pass. Under a plain
|
|
68
94
|
* `vitest run` the console still prints "1 passed", because that is
|
|
@@ -81,7 +107,7 @@ import { driver } from '../lib/driver.js';
|
|
|
81
107
|
import { discoveryFamilies } from '../lib/discovery-capabilities.js';
|
|
82
108
|
import { pollUntilTerminal } from '../lib/polling.js';
|
|
83
109
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
84
|
-
import { discoverOwnedTenant } from '../lib/webhook-receiver.js';
|
|
110
|
+
import { discoverOwnedTenant, resolveRegistrationUrl } from '../lib/webhook-receiver.js';
|
|
85
111
|
|
|
86
112
|
interface DeliveredRequest {
|
|
87
113
|
readonly headers: Record<string, string>;
|
|
@@ -105,7 +131,16 @@ async function startReceiver(): Promise<{ server: Server; url: string; received:
|
|
|
105
131
|
res.end();
|
|
106
132
|
});
|
|
107
133
|
});
|
|
108
|
-
|
|
134
|
+
// Port 0 (ephemeral) by default — nothing outside this process needs to find
|
|
135
|
+
// it. But OPENWOP_WEBHOOK_RECEIVER_URL fronts THIS receiver through a tunnel,
|
|
136
|
+
// and a tunnel has to be pointed at a port the operator knows in ADVANCE. An
|
|
137
|
+
// ephemeral port makes that variable unusable by anyone not reading the port
|
|
138
|
+
// out of a running process — a gap found by standing up a real TLS front and
|
|
139
|
+
// trying to use the feature, not by reading the code. OPENWOP_WEBHOOK_RECEIVER_PORT
|
|
140
|
+
// pins it so `ngrok http <port>` (or a proxy) has a stable target.
|
|
141
|
+
const pinned = Number(process.env['OPENWOP_WEBHOOK_RECEIVER_PORT'] ?? '');
|
|
142
|
+
const bindPort = Number.isInteger(pinned) && pinned > 0 && pinned < 65536 ? pinned : 0;
|
|
143
|
+
await new Promise<void>((resolve) => server.listen(bindPort, '127.0.0.1', () => resolve()));
|
|
109
144
|
const addr = server.address();
|
|
110
145
|
if (typeof addr !== 'object' || addr === null) throw new Error('receiver address unavailable');
|
|
111
146
|
return { server, url: `http://127.0.0.1:${addr.port}/`, received };
|
|
@@ -150,23 +185,43 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
|
|
|
150
185
|
// tenantId is 403'd by a host that scopes subscriptions by membership
|
|
151
186
|
// (RFC 0093). Single-tenant hosts return undefined ⇒ omit tenantId.
|
|
152
187
|
const ownedTenant = await discoverOwnedTenant(driver);
|
|
188
|
+
const registration = resolveRegistrationUrl(receiver.url);
|
|
153
189
|
const reg = await driver.post('/v1/webhooks', {
|
|
154
|
-
url:
|
|
190
|
+
url: registration.url,
|
|
155
191
|
events: ['run.completed'],
|
|
156
192
|
...(ownedTenant ? { tenantId: ownedTenant } : {}),
|
|
157
193
|
});
|
|
158
194
|
|
|
159
|
-
// SSRF guard
|
|
160
|
-
//
|
|
195
|
+
// SSRF guard: if the host rejects the destination, what that MEANS depends
|
|
196
|
+
// on which URL was registered.
|
|
197
|
+
//
|
|
198
|
+
// * No tunnel wired (loopback URL): the host is refusing a private
|
|
199
|
+
// address, which webhooks.md REQUIRES it to do. That is correct
|
|
200
|
+
// behaviour and the scenario is unwitnessable here — `blocked`.
|
|
201
|
+
// * Tunnel wired (public https URL): the host refused a destination the
|
|
202
|
+
// spec makes legitimate. RFC 0093 does not permit rejecting a public
|
|
203
|
+
// https host, so this is a FINDING, not a missing precondition. Fail.
|
|
204
|
+
//
|
|
205
|
+
// Soft-skipping the second case would let a host that rejects everything
|
|
206
|
+
// record `blocked` forever instead of the failure it earned.
|
|
161
207
|
if (reg.status === 400) {
|
|
162
208
|
const body = reg.json as { error?: string };
|
|
163
209
|
if (body.error === 'webhook_url_rejected') {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
'
|
|
210
|
+
if (!registration.tunnelled) {
|
|
211
|
+
// eslint-disable-next-line no-console
|
|
212
|
+
console.warn(
|
|
213
|
+
'[webhook-signed-delivery] host SSRF guard rejected the loopback receiver; ' +
|
|
214
|
+
'set OPENWOP_WEBHOOK_RECEIVER_URL to a public https tunnel in front of it ' +
|
|
215
|
+
'(preferred — relaxes no guard), or OPENWOP_WEBHOOK_ALLOW_PRIVATE=true on the host',
|
|
216
|
+
);
|
|
217
|
+
return softSkip('blocked', 'precondition not met — `body.error === \'webhook_url_rejected\'` returned early (seam, prior step, or fixture unavailable)');
|
|
218
|
+
}
|
|
219
|
+
expect.fail(
|
|
220
|
+
`host rejected the operator-supplied public https receiver (${registration.url}) with ` +
|
|
221
|
+
'webhook_url_rejected. A public https destination is legitimate under ' +
|
|
222
|
+
'webhooks.md §"SSRF protection" and RFC 0093 §"Delivery-time egress validation"; ' +
|
|
223
|
+
'rejecting it is a host defect, not an unmet precondition.',
|
|
168
224
|
);
|
|
169
|
-
return softSkip('blocked', 'precondition not met — `body.error === \'webhook_url_rejected\'` returned early (seam, prior step, or fixture unavailable)');
|
|
170
225
|
}
|
|
171
226
|
}
|
|
172
227
|
|
|
@@ -216,9 +271,19 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
|
|
|
216
271
|
return false;
|
|
217
272
|
}
|
|
218
273
|
});
|
|
274
|
+
// With a tunnel wired this is the assertion that keeps a mis-wired front
|
|
275
|
+
// from reading as a pass. Registration succeeded, so the host believes it
|
|
276
|
+
// has a subscriber; if nothing arrived HERE, the delivery went somewhere
|
|
277
|
+
// this process cannot see and every assertion below it would be vacuous.
|
|
278
|
+
// It fails — it must never soft-skip.
|
|
219
279
|
expect(ourDeliveries.length, driver.describe(
|
|
220
280
|
'webhooks.md §"Delivery"',
|
|
221
|
-
|
|
281
|
+
registration.tunnelled
|
|
282
|
+
? 'host MUST POST at least one event for THIS run to the registered subscriber. ' +
|
|
283
|
+
'Registration was accepted, so zero deliveries observed on the local receiver means ' +
|
|
284
|
+
'either the host did not deliver, or OPENWOP_WEBHOOK_RECEIVER_URL does not actually ' +
|
|
285
|
+
'front this process. Both are failures; neither is a skip.'
|
|
286
|
+
: 'host MUST POST at least one event for THIS run to a registered subscriber after run.completed',
|
|
222
287
|
)).toBeGreaterThan(0);
|
|
223
288
|
|
|
224
289
|
// Validate the FIRST delivery's signature contract. Other deliveries
|
|
@@ -268,4 +333,62 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
|
|
|
268
333
|
expect(del.status).toBeGreaterThanOrEqual(200);
|
|
269
334
|
expect(del.status).toBeLessThan(300);
|
|
270
335
|
});
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* POSITIVE CONTROL for the tunnel path.
|
|
339
|
+
*
|
|
340
|
+
* Without this, the tunnel run passes identically on two very different
|
|
341
|
+
* hosts: one whose SSRF guard is intact and was cleared by a legitimate
|
|
342
|
+
* public destination, and one that has NO GUARD AT ALL and would have
|
|
343
|
+
* accepted the loopback URL just as happily. The green would be reporting on
|
|
344
|
+
* the operator's tunnel rather than on the host's behaviour, and the
|
|
345
|
+
* variable would be doing no work.
|
|
346
|
+
*
|
|
347
|
+
* So: with the tunnel wired, re-register the LOOPBACK url and require that
|
|
348
|
+
* the host still refuses it. That is what makes the sibling test's pass
|
|
349
|
+
* attributable to the guard being cleared rather than absent.
|
|
350
|
+
*
|
|
351
|
+
* A host that ACCEPTS loopback here is not necessarily non-conformant — it
|
|
352
|
+
* may be running with `OPENWOP_WEBHOOK_ALLOW_PRIVATE=true`. But in that
|
|
353
|
+
* configuration the tunnel is demonstrating nothing, and saying so is the
|
|
354
|
+
* control's whole job.
|
|
355
|
+
*/
|
|
356
|
+
it('control: with a tunnel wired, the host still refuses the loopback receiver', async () => {
|
|
357
|
+
if (!(await isWebhookSupported())) {
|
|
358
|
+
return softSkip('inapplicable', '[webhook-signed-delivery] host does not advertise webhook support; skipping');
|
|
359
|
+
}
|
|
360
|
+
if (!process.env.OPENWOP_WEBHOOK_RECEIVER_URL?.trim()) {
|
|
361
|
+
// No tunnel wired ⇒ the sibling test already registers loopback directly
|
|
362
|
+
// and this control has nothing to add.
|
|
363
|
+
return softSkip('inapplicable', 'control applies only when OPENWOP_WEBHOOK_RECEIVER_URL is set');
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const receiver = await startReceiver();
|
|
367
|
+
activeServer = receiver.server;
|
|
368
|
+
|
|
369
|
+
const ownedTenant = await discoverOwnedTenant(driver);
|
|
370
|
+
const reg = await driver.post('/v1/webhooks', {
|
|
371
|
+
url: receiver.url, // deliberately the raw loopback URL, NOT the tunnel
|
|
372
|
+
events: ['run.completed'],
|
|
373
|
+
...(ownedTenant ? { tenantId: ownedTenant } : {}),
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
if (reg.status >= 200 && reg.status < 300) {
|
|
377
|
+
// Clean up the subscription we just created before failing, so a failed
|
|
378
|
+
// control does not leave a live loopback subscriber behind.
|
|
379
|
+
const created = reg.json as { webhookId?: string };
|
|
380
|
+
if (created?.webhookId) {
|
|
381
|
+
await driver.delete(`/v1/webhooks/${encodeURIComponent(created.webhookId)}`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
expect(reg.status, driver.describe(
|
|
386
|
+
'webhooks.md §"SSRF protection"',
|
|
387
|
+
'the server MUST reject loopback subscription URLs at registration time. ' +
|
|
388
|
+
'It was accepted here, so this run cannot attribute the tunnelled test\'s pass to ' +
|
|
389
|
+
'a working guard: a host with no guard produces the same result. Either the guard is ' +
|
|
390
|
+
'absent (a finding) or OPENWOP_WEBHOOK_ALLOW_PRIVATE is set (in which case drop the ' +
|
|
391
|
+
'tunnel — it is not doing anything).',
|
|
392
|
+
)).toBe(400);
|
|
393
|
+
});
|
|
271
394
|
});
|
package/src/setup.ts
CHANGED
|
@@ -278,7 +278,7 @@ afterAll(({}, suite) => {
|
|
|
278
278
|
// the marker detail — never to a pass. Floors still REJECT that row, so the
|
|
279
279
|
// honest bundle row and the pressure to say why both survive. The rule is
|
|
280
280
|
// `resolveFileRecord` (pinned by conformance-execution-witness.test.ts).
|
|
281
|
-
const { disposition, detail } = resolveFileRecord(states, gateReason, assertionCount, softSkipDisposition(file));
|
|
281
|
+
const { disposition, detail } = resolveFileRecord(states, gateReason, assertionCount, softSkipDisposition(file), file);
|
|
282
282
|
const fileRequirementId = requirementIdForFile(file);
|
|
283
283
|
// A scenario that classified ITSELF wins outright — including its `detail` and
|
|
284
284
|
// its `assertionCount`.
|