@openwop/openwop-conformance 1.136.10 → 1.138.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/README.md +267 -2
- package/coverage.md +1 -1
- package/dist/cli.js +8 -1
- package/dist/lib/requirement-ledger.js +8 -0
- package/dist/lib/scenario-disposition.js +30 -0
- package/package.json +1 -1
- package/schemas/CORPUS-STAMP.json +2 -2
- package/schemas/capabilities.schema.json +1 -1
- package/schemas/certification-bundle-v2.schema.json +11 -1
- package/src/cli.ts +8 -1
- package/src/lib/polling.test.ts +80 -0
- package/src/lib/polling.ts +39 -2
- package/src/lib/requirement-ledger.test.ts +75 -0
- package/src/lib/requirement-ledger.ts +9 -0
- package/src/lib/risk-disposition.test.ts +91 -0
- package/src/lib/scenario-disposition.ts +30 -0
- package/src/lib/webhook-receiver.test.ts +76 -0
- package/src/lib/webhook-receiver.ts +24 -3
- package/src/scenarios/certification-floor-enforcement.test.ts +65 -0
- package/src/scenarios/conformance-execution-witness.test.ts +29 -0
- package/src/scenarios/cross-host-traceparent-propagation.test.ts +10 -6
- package/src/scenarios/durability-poison-exhaustion.test.ts +154 -0
- package/src/scenarios/replay-fanout-suppression.test.ts +295 -0
- package/src/scenarios/webhook-receiver-adversarial.test.ts +9 -3
- package/src/scenarios/webhook-signed-delivery.test.ts +23 -6
- package/src/setup.ts +24 -5
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* delivers body B'. Receiver MUST reject with
|
|
18
18
|
* `signature_mismatch`.
|
|
19
19
|
* 2. Tampered HMAC — body is valid; adversary flips a byte of the
|
|
20
|
-
*
|
|
20
|
+
* sha256=<hex> signature. Receiver MUST reject with
|
|
21
21
|
* `signature_mismatch`.
|
|
22
22
|
* 3. Stale timestamp — body + HMAC are valid but timestamp is
|
|
23
23
|
* older than the default 5-minute window. Receiver MUST reject
|
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
createReceiverState,
|
|
43
43
|
signPayload,
|
|
44
44
|
verifyWebhookDelivery,
|
|
45
|
+
SIGNATURE_PREFIX,
|
|
45
46
|
} from '../lib/webhook-receiver.js';
|
|
46
47
|
|
|
47
48
|
describe('webhook-receiver-adversarial: receiver rejects five canonical attacks', () => {
|
|
@@ -93,8 +94,13 @@ describe('webhook-receiver-adversarial: receiver rejects five canonical attacks'
|
|
|
93
94
|
it('case 2: tampered HMAC → signature_mismatch', () => {
|
|
94
95
|
const state = createReceiverState();
|
|
95
96
|
const { signatureHeader, timestampHeader, algorithmHeader } = signPayload(secret, ts, body);
|
|
96
|
-
// Flip one hex character of the
|
|
97
|
-
|
|
97
|
+
// Flip one hex character of the `sha256=<hex>` signature. Derived from
|
|
98
|
+
// SIGNATURE_PREFIX rather than hard-coded: the literal `5` was inside the
|
|
99
|
+
// hex only while the prefix was the (non-spec) `v1=`, and silently moved
|
|
100
|
+
// INTO the prefix when it was corrected to `sha256=` — turning a
|
|
101
|
+
// tampered-HMAC case into a malformed-header one. The test caught it; the
|
|
102
|
+
// constant is what makes it stay caught.
|
|
103
|
+
const flipIndex = SIGNATURE_PREFIX.length + 2;
|
|
98
104
|
const orig = signatureHeader[flipIndex]!;
|
|
99
105
|
const replacement = orig === '0' ? '1' : '0';
|
|
100
106
|
const tampered = signatureHeader.slice(0, flipIndex) + replacement + signatureHeader.slice(flipIndex + 1);
|
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
* 2. `X-openwop-Signature-Algorithm: v1` header is present.
|
|
8
8
|
* 3. `X-openwop-Signature` is a valid HMAC-SHA256 of
|
|
9
9
|
* `${timestamp}.${rawBody}` under the subscription secret.
|
|
10
|
-
* 4. `X-openwop-
|
|
10
|
+
* 4. `X-openwop-Webhook-Id` matches the returned `webhookId`.
|
|
11
|
+
* (This line said `X-openwop-Subscription-Id` until 2026-08-19, two years
|
|
12
|
+
* after the assertion below stopped checking that header — a docblock
|
|
13
|
+
* asserting more than the code did, in the file that documents a
|
|
14
|
+
* security-relevant contract.)
|
|
11
15
|
*
|
|
12
16
|
* Capability-gated: skips when the host does not advertise
|
|
13
17
|
* `capabilities.webhooks.supported = true`.
|
|
@@ -122,10 +126,23 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
|
|
|
122
126
|
|
|
123
127
|
expect(reg.status, driver.describe(
|
|
124
128
|
'webhooks.md §"Register"',
|
|
125
|
-
'POST /v1/webhooks MUST return 201 with
|
|
129
|
+
'POST /v1/webhooks MUST return 201 with webhookId + secret on success',
|
|
126
130
|
)).toBe(201);
|
|
127
|
-
|
|
128
|
-
|
|
131
|
+
// `webhookId`, NOT `subscriptionId` (corrected 2026-08-19). `webhooks.md`
|
|
132
|
+
// §"Register" shows `{"webhookId": "wh_a3b9c2", ...}`, `api/openapi.yaml`
|
|
133
|
+
// declares the 201 body `required: [webhookId]` with no `subscriptionId`
|
|
134
|
+
// property at all, and the sibling `webhook-tenant-isolation.test.ts` reads
|
|
135
|
+
// `webhookId`. This file required a field the contract does not define, so a
|
|
136
|
+
// SPEC-CONFORMING host failed at the first assertion — the suite being
|
|
137
|
+
// different from the spec, which is worse than the stricter-than-spec case
|
|
138
|
+
// COMPATIBILITY.md §2.3 forbids. Reported by a tier-2 host emitting exactly
|
|
139
|
+
// what the spec shows. The postgres reference host returns both names, which
|
|
140
|
+
// is why nothing went red here.
|
|
141
|
+
const sub = reg.json as { webhookId: string; secret: string };
|
|
142
|
+
expect(typeof sub.webhookId, driver.describe(
|
|
143
|
+
'api/openapi.yaml registerWebhook 201',
|
|
144
|
+
'the 201 body MUST carry `webhookId` (required) — `subscriptionId` is not in the contract',
|
|
145
|
+
)).toBe('string');
|
|
129
146
|
expect(typeof sub.secret).toBe('string');
|
|
130
147
|
expect(sub.secret.length).toBeGreaterThan(0);
|
|
131
148
|
|
|
@@ -174,7 +191,7 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
|
|
|
174
191
|
expect(
|
|
175
192
|
first.headers['x-openwop-webhook-id'],
|
|
176
193
|
driver.describe('webhooks.md §"Delivery headers"', 'X-openwop-Webhook-Id MUST carry the subscription id'),
|
|
177
|
-
).toBe(sub.
|
|
194
|
+
).toBe(sub.webhookId);
|
|
178
195
|
|
|
179
196
|
const timestamp = first.headers['x-openwop-timestamp'];
|
|
180
197
|
expect(
|
|
@@ -201,7 +218,7 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
|
|
|
201
218
|
expect(event.runId).toBe(runId);
|
|
202
219
|
|
|
203
220
|
// Cleanup: unregister.
|
|
204
|
-
const del = await driver.delete(`/v1/webhooks/${encodeURIComponent(sub.
|
|
221
|
+
const del = await driver.delete(`/v1/webhooks/${encodeURIComponent(sub.webhookId)}`);
|
|
205
222
|
expect(del.status).toBeGreaterThanOrEqual(200);
|
|
206
223
|
expect(del.status).toBeLessThan(300);
|
|
207
224
|
});
|
package/src/setup.ts
CHANGED
|
@@ -32,7 +32,7 @@ import { McpFakeServer, setMcpFakeServer } from './lib/mcp-fake-server.js';
|
|
|
32
32
|
import { A2AFakePeer, setA2AFakePeer } from './lib/a2a-fake-peer.js';
|
|
33
33
|
import { afterAll, afterEach, beforeAll, expect } from 'vitest';
|
|
34
34
|
import { basename } from 'node:path';
|
|
35
|
-
import { recordRequirement, journalLength, journalSince } from './lib/requirement-ledger.js';
|
|
35
|
+
import { recordRequirement, hasRequirement, journalLength, journalSince } from './lib/requirement-ledger.js';
|
|
36
36
|
import { requirementIdForFile, resolveFileRecord, type FileTestState } from './lib/scenario-disposition.js';
|
|
37
37
|
import { softSkipDisposition } from './lib/soft-skip.js';
|
|
38
38
|
import type { DiscoveryPayload } from './lib/profiles.js';
|
|
@@ -279,10 +279,29 @@ afterAll(({}, suite) => {
|
|
|
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
281
|
const { disposition, detail } = resolveFileRecord(states, gateReason, assertionCount, softSkipDisposition(file));
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
282
|
+
const fileRequirementId = requirementIdForFile(file);
|
|
283
|
+
// A scenario that classified ITSELF wins outright — including its `detail` and
|
|
284
|
+
// its `assertionCount`.
|
|
285
|
+
//
|
|
286
|
+
// The `catch` below has always made the explicit record win when the two
|
|
287
|
+
// DISAGREE (`recordRequirement` throws on a conflicting disposition). It did
|
|
288
|
+
// not when they AGREE: the automatic call then reached `ledger.set` and
|
|
289
|
+
// silently replaced the scenario's own detail and count with the file-level
|
|
290
|
+
// ones. Invisible until 2026-08-19, when `resolveFileRecord` started attaching
|
|
291
|
+
// a `partial-witness:` marker — a scenario that recorded `executed-pass` for a
|
|
292
|
+
// requirement it really did exercise, in a file whose LAST leg soft-skipped,
|
|
293
|
+
// would have been stamped "may not have witnessed this" over its own explicit
|
|
294
|
+
// finding. That would inject false positives into exactly the measurement the
|
|
295
|
+
// marker exists to produce.
|
|
296
|
+
//
|
|
297
|
+
// So the comment describing this line was true of half the cases. It is true
|
|
298
|
+
// of both now.
|
|
299
|
+
if (!hasRequirement(fileRequirementId)) {
|
|
300
|
+
try {
|
|
301
|
+
recordRequirement(fileRequirementId, disposition, detail, { assertionCount });
|
|
302
|
+
} catch {
|
|
303
|
+
/* never fail a file for bookkeeping */
|
|
304
|
+
}
|
|
286
305
|
}
|
|
287
306
|
_fileStates.delete(file);
|
|
288
307
|
_fileAssertions.delete(file);
|