@crawlcheck/sdk 1.4.0 → 1.5.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 +21 -0
- package/dist/index.d.ts +58 -1
- package/dist/index.js +50 -0
- package/dist/revocations.d.ts +25 -0
- package/dist/revocations.js +75 -0
- package/package.json +1 -1
- package/test/fixtures/revocations.json +483 -0
- package/test/revocations.test.js +77 -0
package/README.md
CHANGED
|
@@ -50,6 +50,27 @@ v.decisions.transact; // allow | warn | require_confirmation | block | unsuppor
|
|
|
50
50
|
|
|
51
51
|
`stapleCheck(record, { host, publishedKids })` does the offline check on its own. Spec: https://crawlcheck.io/spec/staple
|
|
52
52
|
|
|
53
|
+
## Hold the revocation list offline
|
|
54
|
+
|
|
55
|
+
CrawlCheck signs a list of domains whose connect or transact decision is block now: an owner-declared incident, a suspected impersonation of a top-10,000 brand, or an unconfirmed change to a payment, checkout, wallet, login, mail or MCP field. `syncRevocations()` fetches it (a `?since=` delta after the first sync), checks the signature and the published key, and applies it. From then on `guard()` refuses a listed domain for a listed action with no request at all, so it keeps working with the network cut:
|
|
56
|
+
|
|
57
|
+
```js
|
|
58
|
+
import fs from "node:fs";
|
|
59
|
+
import { CrawlCheck, newRevocationStore } from "@crawlcheck/sdk";
|
|
60
|
+
const store = fs.existsSync("cc-revocations.json") ? JSON.parse(fs.readFileSync("cc-revocations.json", "utf8")) : newRevocationStore();
|
|
61
|
+
const client = new CrawlCheck({ revocationStore: store });
|
|
62
|
+
await client.syncRevocations(); // every poll_seconds (60), and before going offline
|
|
63
|
+
fs.writeFileSync("cc-revocations.json", JSON.stringify(client.revocations));
|
|
64
|
+
const g = await client.guard("pay.example.com", "transact");
|
|
65
|
+
g.revoked; // set when the held list refused it: { domain, actions, causes, since }
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`revokedFor(store, domain, action)` is the same check on its own. Reading and citing are never revoked by this list.
|
|
69
|
+
|
|
70
|
+
## Look up without naming the domain
|
|
71
|
+
|
|
72
|
+
`new CrawlCheck({ privateLookups: true })` makes `resolve()` and `resolveFor()` send only the first 2 hex characters of sha256(domain); the signed answer is picked out of the bucket and verified locally. A domain CrawlCheck has never measured raises a 404 instead of a just-in-time answer, because making one would name it. `preflight()` and `guard()` still send the domain: a receipt is bound to it.
|
|
73
|
+
|
|
53
74
|
## Preflight inside your MCP client
|
|
54
75
|
|
|
55
76
|
Wrap the official MCP client so `connect` and `callTool` are decided before anything is sent ([proposal](https://crawlcheck.io/spec/mcp-preflight)):
|
package/dist/index.d.ts
CHANGED
|
@@ -265,7 +265,8 @@ export interface GuardResult {
|
|
|
265
265
|
verification: (DocumentVerification & Trust) | null;
|
|
266
266
|
header: Record<string, string> | null;
|
|
267
267
|
why: string[];
|
|
268
|
-
split_view?: LogFork[];
|
|
268
|
+
split_view?: LogFork[]; /** Set when the domain was refused from the held revocation list, with no network call. */
|
|
269
|
+
revoked?: RevokedEntry;
|
|
269
270
|
}
|
|
270
271
|
export interface GuardedFetchOptions extends GuardOptions {
|
|
271
272
|
action?: Action;
|
|
@@ -421,6 +422,46 @@ export declare const stapleCheck: (record: string, opts: {
|
|
|
421
422
|
publishedKids: string[];
|
|
422
423
|
now?: number;
|
|
423
424
|
}) => Promise<StapleCheck>;
|
|
425
|
+
export interface RevokedEntry {
|
|
426
|
+
domain?: string;
|
|
427
|
+
actions: string[];
|
|
428
|
+
causes: string[];
|
|
429
|
+
state: string | null;
|
|
430
|
+
since: string | null;
|
|
431
|
+
updated_at: string | null;
|
|
432
|
+
}
|
|
433
|
+
/** The signed revocation list held locally (plain JSON: persist it between runs). */
|
|
434
|
+
export interface RevocationStore {
|
|
435
|
+
v: 1;
|
|
436
|
+
generated_at: string | null;
|
|
437
|
+
seq: string | null;
|
|
438
|
+
synced_at: string | null;
|
|
439
|
+
revoked: Record<string, RevokedEntry>;
|
|
440
|
+
}
|
|
441
|
+
export interface RevocationSync {
|
|
442
|
+
applied: boolean;
|
|
443
|
+
full: boolean;
|
|
444
|
+
added: number;
|
|
445
|
+
removed: number;
|
|
446
|
+
reason: string | null;
|
|
447
|
+
total: number;
|
|
448
|
+
generated_at: string | null;
|
|
449
|
+
verification?: DocumentVerification & Trust;
|
|
450
|
+
}
|
|
451
|
+
/** A new, empty revocation store. */
|
|
452
|
+
export declare const newRevocationStore: () => RevocationStore;
|
|
453
|
+
/** Apply a VERIFIED crawlcheck-revocations document (full list or ?since= delta) to a store. */
|
|
454
|
+
export declare const applyRevocations: (store: RevocationStore, doc: unknown, now?: number) => {
|
|
455
|
+
applied: boolean;
|
|
456
|
+
full: boolean;
|
|
457
|
+
added: number;
|
|
458
|
+
removed: number;
|
|
459
|
+
reason: string | null;
|
|
460
|
+
};
|
|
461
|
+
/** The entry that revokes this domain (or a parent domain) for this action, or null. Offline. */
|
|
462
|
+
export declare const revokedFor: (store: RevocationStore, domain: string, action?: string) => (RevokedEntry & {
|
|
463
|
+
domain: string;
|
|
464
|
+
}) | null;
|
|
424
465
|
export interface McpPreflightOptions extends PreflightOptions {
|
|
425
466
|
/** Proceed on warn too (default false). */
|
|
426
467
|
allowWarn?: boolean;
|
|
@@ -497,6 +538,10 @@ export interface ClientOptions {
|
|
|
497
538
|
headers?: Record<string, string>;
|
|
498
539
|
/** Transparency-log store (newLogStore()). Every Resolve answer's signed tree head is checked against it; keep it across runs to catch a split view. Default: a fresh in-memory store. */
|
|
499
540
|
logStore?: LogStore;
|
|
541
|
+
/** Revocation store (newRevocationStore()). syncRevocations() fills it; guard() refuses a listed domain for a listed action without a network call. Default: a fresh, empty store. */
|
|
542
|
+
revocationStore?: RevocationStore;
|
|
543
|
+
/** Private mode: resolve() and resolveFor() send only a 2-hex prefix of sha256(domain) (resolvePrivate), never the domain. A domain with no public measurement then raises a 404 CrawlCheckError instead of a just-in-time answer, which would name it. preflight() and guard() still name the domain: a receipt is bound to it. */
|
|
544
|
+
privateLookups?: boolean;
|
|
500
545
|
}
|
|
501
546
|
export declare class CrawlCheckError extends Error {
|
|
502
547
|
readonly status: number;
|
|
@@ -520,6 +565,10 @@ export declare class CrawlCheck {
|
|
|
520
565
|
private readonly extra;
|
|
521
566
|
/** The transparency-log store this client checks every signed tree head against. Persist it (JSON) between runs. */
|
|
522
567
|
readonly log: LogStore;
|
|
568
|
+
/** The signed revocation list this client holds. Persist it (JSON) between runs; syncRevocations() keeps it current. */
|
|
569
|
+
readonly revocations: RevocationStore;
|
|
570
|
+
/** Private mode (ClientOptions.privateLookups). */
|
|
571
|
+
readonly privateLookups: boolean;
|
|
523
572
|
constructor(opts?: ClientOptions);
|
|
524
573
|
/** Any documented GET, typed from the OpenAPI document: client.get("/api/explain", { domain: "example.com" }). */
|
|
525
574
|
get<P extends keyof paths>(path: P, query?: QueryOf<P, "get">): Promise<ResponseOf<P, "get">>;
|
|
@@ -560,6 +609,14 @@ export declare class CrawlCheck {
|
|
|
560
609
|
sha256: string;
|
|
561
610
|
})[];
|
|
562
611
|
}>;
|
|
612
|
+
/**
|
|
613
|
+
* Fetch the signed revocation list (a ?since= delta when the store already holds one), verify it (integrity and a
|
|
614
|
+
* published signing key) and apply it. A delta that no longer reaches back is replaced by the full list. Nothing is
|
|
615
|
+
* applied from a document that does not verify. Call it on a timer (poll_seconds, 60) and before going offline.
|
|
616
|
+
*/
|
|
617
|
+
syncRevocations(opts?: {
|
|
618
|
+
full?: boolean;
|
|
619
|
+
}): Promise<RevocationSync>;
|
|
563
620
|
/** The policy engine: a signed decision receipt (allow | warn | require_confirmation | block | unsupported). A policy can only make the decision stricter. */
|
|
564
621
|
preflight(domain: string, action?: Action, opts?: PreflightOptions): Promise<DecisionReceipt>;
|
|
565
622
|
/** Verify a signed document here. With onlineKeys (default), its signing key must also be in the published directory. */
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import * as V from "./verify.js";
|
|
|
2
2
|
import * as L from "./log.js";
|
|
3
3
|
import * as S from "./staple.js";
|
|
4
4
|
import * as M from "./mcp.js";
|
|
5
|
+
import * as R from "./revocations.js";
|
|
5
6
|
function withTrust(v, kid, published, related = []) {
|
|
6
7
|
let checks = v.checks;
|
|
7
8
|
let issuer = null;
|
|
@@ -136,6 +137,12 @@ export const parseStaple = S.parseStaple;
|
|
|
136
137
|
export const stapleFromResponse = S.stapleFromResponse;
|
|
137
138
|
/** Verify a staple offline: published key, signature, the record names opts.host (or a parent), fresh_until in the future. Expired or foreign staples are never valid. */
|
|
138
139
|
export const stapleCheck = S.stapleCheck;
|
|
140
|
+
/** A new, empty revocation store. */
|
|
141
|
+
export const newRevocationStore = R.newRevocationStore;
|
|
142
|
+
/** Apply a VERIFIED crawlcheck-revocations document (full list or ?since= delta) to a store. */
|
|
143
|
+
export const applyRevocations = R.applyRevocations;
|
|
144
|
+
/** The entry that revokes this domain (or a parent domain) for this action, or null. Offline. */
|
|
145
|
+
export const revokedFor = R.revokedFor;
|
|
139
146
|
/** Thrown by the hooks when a connect or tools/call is refused; the request was never sent. */
|
|
140
147
|
export const McpPreflightRefused = M.McpPreflightRefused;
|
|
141
148
|
/** The two hooks (beforeConnect, beforeToolCall) plus afterConnect for lockfiles, for any MCP client stack. */
|
|
@@ -166,11 +173,17 @@ export class CrawlCheck {
|
|
|
166
173
|
extra;
|
|
167
174
|
/** The transparency-log store this client checks every signed tree head against. Persist it (JSON) between runs. */
|
|
168
175
|
log;
|
|
176
|
+
/** The signed revocation list this client holds. Persist it (JSON) between runs; syncRevocations() keeps it current. */
|
|
177
|
+
revocations;
|
|
178
|
+
/** Private mode (ClientOptions.privateLookups). */
|
|
179
|
+
privateLookups;
|
|
169
180
|
constructor(opts = {}) {
|
|
170
181
|
this.base = (opts.base ?? "https://crawlcheck.io").replace(/\/+$/, "");
|
|
171
182
|
this.key = opts.key;
|
|
172
183
|
this.extra = opts.headers ?? {};
|
|
173
184
|
this.log = opts.logStore ?? newLogStore();
|
|
185
|
+
this.revocations = opts.revocationStore ?? newRevocationStore();
|
|
186
|
+
this.privateLookups = !!opts.privateLookups;
|
|
174
187
|
const f = opts.fetch ?? globalThis.fetch;
|
|
175
188
|
if (typeof f !== "function")
|
|
176
189
|
throw new Error("no fetch available: pass { fetch } (Node 18+ and every browser have one)");
|
|
@@ -187,6 +200,17 @@ export class CrawlCheck {
|
|
|
187
200
|
// ── read before acting ──
|
|
188
201
|
/** The signed ResolveV1 answer: crawl policy, delivery, machine files, capabilities, entity, findings, freshness and a decision per action. */
|
|
189
202
|
async resolve(domain) {
|
|
203
|
+
if (this.privateLookups) { // pfx2: only the hash prefix leaves this process
|
|
204
|
+
const p = await this.resolvePrivate(domain);
|
|
205
|
+
if (!p.ready)
|
|
206
|
+
throw new CrawlCheckError(503, "/api/v1/resolve-prefix", { error: p.why || "the prefix index is not built yet" });
|
|
207
|
+
if (!p.found)
|
|
208
|
+
throw new CrawlCheckError(404, "/api/v1/resolve-prefix", { error: "CrawlCheck holds no public measurement of " + domain + "; in private mode the domain is never sent, so no just-in-time answer is made" });
|
|
209
|
+
if (!p.accepted)
|
|
210
|
+
throw new CrawlCheckError(502, "/api/v1/resolve-prefix", { error: "the answer in the bucket did not verify: " + (p.verification ? p.verification.summary : "no verification") });
|
|
211
|
+
await logObserveAnswer(this.log, p.answer).catch(() => null);
|
|
212
|
+
return p.answer;
|
|
213
|
+
}
|
|
190
214
|
const a = await this.get("/api/v1/resolve", { domain });
|
|
191
215
|
await logObserveAnswer(this.log, a).catch(() => null); // a head that contradicts one this client already holds is recorded in this.log.forks
|
|
192
216
|
return a;
|
|
@@ -242,6 +266,29 @@ export class CrawlCheck {
|
|
|
242
266
|
}
|
|
243
267
|
/** Check heads another client sent (logHeads on their side) against this client's store. */
|
|
244
268
|
logGossip(sths) { return logGossip(this.log, sths); }
|
|
269
|
+
/**
|
|
270
|
+
* Fetch the signed revocation list (a ?since= delta when the store already holds one), verify it (integrity and a
|
|
271
|
+
* published signing key) and apply it. A delta that no longer reaches back is replaced by the full list. Nothing is
|
|
272
|
+
* applied from a document that does not verify. Call it on a timer (poll_seconds, 60) and before going offline.
|
|
273
|
+
*/
|
|
274
|
+
async syncRevocations(opts = {}) {
|
|
275
|
+
const st = this.revocations;
|
|
276
|
+
const get = async (since) => (await this.req("GET", "/api/v1/revocations", since ? { since } : undefined));
|
|
277
|
+
let doc = await get(opts.full || !st.generated_at ? null : st.generated_at);
|
|
278
|
+
const verify = async (d) => this.verifyDocument(d, true);
|
|
279
|
+
let v = await verify(doc);
|
|
280
|
+
if (!v.accepted)
|
|
281
|
+
return { applied: false, full: false, added: 0, removed: 0, reason: "the revocation list did not verify: " + v.summary, total: Object.keys(st.revoked).length, generated_at: st.generated_at, verification: v };
|
|
282
|
+
let r = applyRevocations(st, doc);
|
|
283
|
+
if (!r.applied && doc && doc.since) {
|
|
284
|
+
doc = await get(null);
|
|
285
|
+
v = await verify(doc);
|
|
286
|
+
if (!v.accepted)
|
|
287
|
+
return { applied: false, full: true, added: 0, removed: 0, reason: "the full revocation list did not verify: " + v.summary, total: Object.keys(st.revoked).length, generated_at: st.generated_at, verification: v };
|
|
288
|
+
r = applyRevocations(st, doc);
|
|
289
|
+
}
|
|
290
|
+
return Object.assign({}, r, { total: Object.keys(st.revoked).length, generated_at: st.generated_at, verification: v });
|
|
291
|
+
}
|
|
245
292
|
/** The policy engine: a signed decision receipt (allow | warn | require_confirmation | block | unsupported). A policy can only make the decision stricter. */
|
|
246
293
|
async preflight(domain, action = "read", opts = {}) {
|
|
247
294
|
const b = { domain, action };
|
|
@@ -270,6 +317,9 @@ export class CrawlCheck {
|
|
|
270
317
|
async guard(domain, action = "read", opts = {}) {
|
|
271
318
|
const nonce = opts.nonce || randomNonce();
|
|
272
319
|
const no = (why, receipt = null, verification = null) => ({ proceed: false, receipt, verification, header: null, why });
|
|
320
|
+
const rv = revokedFor(this.revocations, normalizeDomain(domain) || domain, action); // rvd1: refused offline, before any request
|
|
321
|
+
if (rv)
|
|
322
|
+
return Object.assign(no([rv.domain + " is on CrawlCheck's signed revocation list for " + action + " (" + (rv.causes.join(", ") || rv.state || "revoked") + (rv.since ? ", since " + rv.since : "") + "; list of " + this.revocations.generated_at + ")"]), { revoked: rv });
|
|
273
323
|
let receipt;
|
|
274
324
|
try {
|
|
275
325
|
receipt = await this.preflight(domain, action, Object.assign({}, opts, { nonce }));
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** An empty store. */
|
|
2
|
+
export function newRevocationStore(): {
|
|
3
|
+
v: number;
|
|
4
|
+
generated_at: null;
|
|
5
|
+
seq: null;
|
|
6
|
+
synced_at: null;
|
|
7
|
+
revoked: {};
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Apply a verified crawlcheck-revocations document. A full list (since null) replaces the store; a delta replaces each
|
|
11
|
+
* revoked entry it carries and removes each cleared or lifted one. A delta that is not complete (delta_complete false),
|
|
12
|
+
* or one older than the store, is refused: fetch the full list instead. Returns { applied, full, added, removed, reason }.
|
|
13
|
+
*/
|
|
14
|
+
export function applyRevocations(store: any, doc: any, now: any): {
|
|
15
|
+
applied: boolean;
|
|
16
|
+
full: boolean;
|
|
17
|
+
added: number;
|
|
18
|
+
removed: number;
|
|
19
|
+
reason: null;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Is this domain (or a parent domain) revoked for this action in the store? Returns the entry with the domain that
|
|
23
|
+
* matched, or null. Reading and citing are never revoked by this list.
|
|
24
|
+
*/
|
|
25
|
+
export function revokedFor(store: any, domain: any, action: any): any;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Offline revocations (rvd1): hold CrawlCheck's signed revocation list and refuse a listed domain for a listed action
|
|
2
|
+
// with no network call. A store is plain JSON; persist it between runs. Only a document whose signature was checked
|
|
3
|
+
// should be applied (the client's syncRevocations does that). https://crawlcheck.io/docs/api#revocations
|
|
4
|
+
/** An empty store. */
|
|
5
|
+
export function newRevocationStore() { return { v: 1, generated_at: null, seq: null, synced_at: null, revoked: {} }; }
|
|
6
|
+
function norm(d) { return String(d || "").toLowerCase().trim().replace(/\.$/, "").replace(/^www\./, ""); }
|
|
7
|
+
/**
|
|
8
|
+
* Apply a verified crawlcheck-revocations document. A full list (since null) replaces the store; a delta replaces each
|
|
9
|
+
* revoked entry it carries and removes each cleared or lifted one. A delta that is not complete (delta_complete false),
|
|
10
|
+
* or one older than the store, is refused: fetch the full list instead. Returns { applied, full, added, removed, reason }.
|
|
11
|
+
*/
|
|
12
|
+
export function applyRevocations(store, doc, now) {
|
|
13
|
+
const out = { applied: false, full: false, added: 0, removed: 0, reason: null };
|
|
14
|
+
if (!store || store.v !== 1) {
|
|
15
|
+
out.reason = "not a revocation store (newRevocationStore())";
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
if (!doc || doc.kind !== "crawlcheck-revocations" || !Array.isArray(doc.revoked)) {
|
|
19
|
+
out.reason = "not a crawlcheck-revocations document";
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
if (store.generated_at && doc.generated_at && Date.parse(doc.generated_at) < Date.parse(store.generated_at)) {
|
|
23
|
+
out.reason = "older than the list already held";
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
const full = !doc.since;
|
|
27
|
+
if (!full && doc.delta_complete === false) {
|
|
28
|
+
out.reason = "the delta does not reach back to " + doc.since + ": fetch the full list";
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
if (!full && store.generated_at && Date.parse(doc.since) > Date.parse(store.generated_at)) {
|
|
32
|
+
out.reason = "the delta starts after the list held (" + store.generated_at + "): fetch the full list";
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
const next = full ? {} : Object.assign({}, store.revoked);
|
|
36
|
+
for (const l of doc.cleared || []) {
|
|
37
|
+
const d = norm(l && l.domain);
|
|
38
|
+
if (d && next[d]) {
|
|
39
|
+
delete next[d];
|
|
40
|
+
out.removed++;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
for (const e of doc.revoked) {
|
|
44
|
+
const d = norm(e && e.domain);
|
|
45
|
+
if (!d || !Array.isArray(e.actions))
|
|
46
|
+
continue;
|
|
47
|
+
if (!next[d])
|
|
48
|
+
out.added++;
|
|
49
|
+
next[d] = { actions: e.actions.slice(), causes: Array.isArray(e.causes) ? e.causes.slice() : [], state: e.state || null, since: e.since || null, updated_at: e.updated_at || null };
|
|
50
|
+
}
|
|
51
|
+
if (full)
|
|
52
|
+
out.removed = Math.max(0, Object.keys(store.revoked).filter((d) => !next[d]).length);
|
|
53
|
+
store.revoked = next;
|
|
54
|
+
store.generated_at = doc.generated_at || null;
|
|
55
|
+
store.seq = doc.seq != null ? String(doc.seq) : null;
|
|
56
|
+
store.synced_at = new Date(now || Date.now()).toISOString();
|
|
57
|
+
out.applied = true;
|
|
58
|
+
out.full = full;
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Is this domain (or a parent domain) revoked for this action in the store? Returns the entry with the domain that
|
|
63
|
+
* matched, or null. Reading and citing are never revoked by this list.
|
|
64
|
+
*/
|
|
65
|
+
export function revokedFor(store, domain, action) {
|
|
66
|
+
if (!store || !store.revoked)
|
|
67
|
+
return null;
|
|
68
|
+
const labels = norm(domain).split(".");
|
|
69
|
+
for (let i = 0; i < labels.length - 1; i++) {
|
|
70
|
+
const d = labels.slice(i).join("."), e = store.revoked[d];
|
|
71
|
+
if (e && (!action || e.actions.indexOf(action) >= 0))
|
|
72
|
+
return Object.assign({ domain: d }, e);
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crawlcheck/sdk",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Typed client for the CrawlCheck API (generated from its OpenAPI document) and the zero-dependency offline verifier for evidence bundles and remediation receipts.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
{
|
|
2
|
+
"note": "made by the Worker's own code in a test harness with a throwaway key (rvd1)",
|
|
3
|
+
"full": {
|
|
4
|
+
"kind": "crawlcheck-revocations",
|
|
5
|
+
"v": 1,
|
|
6
|
+
"generated_at": "2026-10-11T13:14:55.824Z",
|
|
7
|
+
"seq": "0.1",
|
|
8
|
+
"since": null,
|
|
9
|
+
"revoked": [
|
|
10
|
+
{
|
|
11
|
+
"domain": "shop.example",
|
|
12
|
+
"state": "automated",
|
|
13
|
+
"actions": [
|
|
14
|
+
"transact"
|
|
15
|
+
],
|
|
16
|
+
"decision": "block",
|
|
17
|
+
"causes": [
|
|
18
|
+
"high_risk_field_changed"
|
|
19
|
+
],
|
|
20
|
+
"since": "2026-10-11T13:14:55.703Z",
|
|
21
|
+
"updated_at": "2026-10-11T13:14:55.703Z",
|
|
22
|
+
"observed_at": "2026-10-11T13:14:55.702Z",
|
|
23
|
+
"detail": {
|
|
24
|
+
"high_risk_field_changed": {
|
|
25
|
+
"fields": [
|
|
26
|
+
"checkout_host"
|
|
27
|
+
],
|
|
28
|
+
"changed_at": "2026-10-11T12:14:55.702Z",
|
|
29
|
+
"block_lifts_by": "2026-10-18T12:14:55.702Z"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"check": "https://crawlcheck.io/api/v1/resolve?domain=shop.example"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"domain": "xn--pple-43d.com",
|
|
36
|
+
"state": "automated",
|
|
37
|
+
"actions": [
|
|
38
|
+
"connect",
|
|
39
|
+
"transact"
|
|
40
|
+
],
|
|
41
|
+
"decision": "block",
|
|
42
|
+
"causes": [
|
|
43
|
+
"impersonation_suspected"
|
|
44
|
+
],
|
|
45
|
+
"since": "2026-10-11T13:14:55.703Z",
|
|
46
|
+
"updated_at": "2026-10-11T13:14:55.703Z",
|
|
47
|
+
"observed_at": "2026-10-11T13:14:55.702Z",
|
|
48
|
+
"detail": {
|
|
49
|
+
"impersonation_suspected": {
|
|
50
|
+
"brand": "apple.com",
|
|
51
|
+
"technique": "idn_homoglyph"
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"check": "https://crawlcheck.io/api/v1/resolve?domain=xn--pple-43d.com"
|
|
55
|
+
}
|
|
56
|
+
],
|
|
57
|
+
"active_total": 2,
|
|
58
|
+
"cleared": [],
|
|
59
|
+
"delta_complete": true,
|
|
60
|
+
"automated_built_at": "2026-10-11T13:14:55.703Z",
|
|
61
|
+
"by_cause": {
|
|
62
|
+
"high_risk_field_changed": 1,
|
|
63
|
+
"impersonation_suspected": 1
|
|
64
|
+
},
|
|
65
|
+
"reading": "Domains whose connect or transact decision is block now, for one of three causes: the owner declared an incident (owner_incident), CrawlCheck suspects an impersonation of a top-10,000 brand (impersonation_suspected: connect and transact), or a high-risk field (payment processor, checkout host, wallet, OAuth server, mail host, MCP endpoint) changed and the owner has not confirmed it within 7 days (high_risk_field_changed: transact). Hold this list and refuse those actions offline. With ?since=<generated_at> you get only what was added or changed since (revoked: replace your entry for that domain) and what left the list since (cleared, lifted: remove it); when delta_complete is false the logs no longer reach back that far, so fetch the full list.",
|
|
66
|
+
"poll_seconds": 60,
|
|
67
|
+
"rebuilt": "owner incidents within a minute; automated entries every batch window (:02, :32, :47 UTC)",
|
|
68
|
+
"sdk": "syncRevocations() / sync_revocations() hold it; guard() refuses a listed domain for a listed action with no network call",
|
|
69
|
+
"code": {
|
|
70
|
+
"version_id": null,
|
|
71
|
+
"script_sha16": null,
|
|
72
|
+
"state": "unknown",
|
|
73
|
+
"deployed": null,
|
|
74
|
+
"build_log_entry": null,
|
|
75
|
+
"score_version": 31,
|
|
76
|
+
"scoring_policy_sha256": "86898d2a8c88d6b56e6710f3aadf4375cbb5342d4a2f9aa017b536d95602d671",
|
|
77
|
+
"decision_rules_sha256": "18dd5636e0896692ab518050cb401744edea2e6f1086dff94fead314959bdc3e",
|
|
78
|
+
"check": "https://crawlcheck.io/api/v1/builds"
|
|
79
|
+
},
|
|
80
|
+
"sha256": "13c782666de404f8c170232769a127acbd34d38c250b90c1ac3b6320b01c56b2",
|
|
81
|
+
"signature": {
|
|
82
|
+
"v": 1,
|
|
83
|
+
"alg": "Ed25519",
|
|
84
|
+
"kid": "m3JoWSDhN2k3t9MQL11mlWNGm5O5JEJ5dE8ekCIP5ss",
|
|
85
|
+
"over": "sha256",
|
|
86
|
+
"message": "crawlcheck-data-v1\n13c782666de404f8c170232769a127acbd34d38c250b90c1ac3b6320b01c56b2",
|
|
87
|
+
"sig": "-kn4YOROgiDc2Fk8eMjSdRB6nG6SlsVsB-6b6xkrsrJFPtsksirHHiin7cJa9RD9If4vDB-9Fn8hkJ65Xr5JCA",
|
|
88
|
+
"signed_at": "2026-10-11T13:14:55.869Z",
|
|
89
|
+
"key": {
|
|
90
|
+
"kid": "m3JoWSDhN2k3t9MQL11mlWNGm5O5JEJ5dE8ekCIP5ss",
|
|
91
|
+
"jwk": {
|
|
92
|
+
"kty": "OKP",
|
|
93
|
+
"crv": "Ed25519",
|
|
94
|
+
"x": "3MSOmtiA71yc0dqVVi2S6abJYKX65mFsosURtK0B0c0"
|
|
95
|
+
},
|
|
96
|
+
"published_in": "https://crawlcheck.io/.well-known/http-message-signatures-directory"
|
|
97
|
+
},
|
|
98
|
+
"canonical": "sha256 over canonical JSON of every field except sha256 and signature"
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
"delta": {
|
|
102
|
+
"kind": "crawlcheck-revocations",
|
|
103
|
+
"v": 1,
|
|
104
|
+
"generated_at": "2026-10-11T13:14:55.917Z",
|
|
105
|
+
"seq": "0.2",
|
|
106
|
+
"since": "2026-10-11T13:14:55.824Z",
|
|
107
|
+
"revoked": [
|
|
108
|
+
{
|
|
109
|
+
"domain": "paypa1.com",
|
|
110
|
+
"state": "automated",
|
|
111
|
+
"actions": [
|
|
112
|
+
"connect",
|
|
113
|
+
"transact"
|
|
114
|
+
],
|
|
115
|
+
"decision": "block",
|
|
116
|
+
"causes": [
|
|
117
|
+
"impersonation_suspected"
|
|
118
|
+
],
|
|
119
|
+
"since": "2026-10-11T13:14:55.915Z",
|
|
120
|
+
"updated_at": "2026-10-11T13:14:55.915Z",
|
|
121
|
+
"observed_at": "2026-10-11T13:14:55.914Z",
|
|
122
|
+
"detail": {
|
|
123
|
+
"impersonation_suspected": {
|
|
124
|
+
"brand": "paypal.com",
|
|
125
|
+
"technique": "character_swap"
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
"check": "https://crawlcheck.io/api/v1/resolve?domain=paypa1.com"
|
|
129
|
+
}
|
|
130
|
+
],
|
|
131
|
+
"active_total": 2,
|
|
132
|
+
"cleared": [
|
|
133
|
+
{
|
|
134
|
+
"domain": "shop.example",
|
|
135
|
+
"state": "lifted",
|
|
136
|
+
"causes": [
|
|
137
|
+
"high_risk_field_changed"
|
|
138
|
+
],
|
|
139
|
+
"actions": [
|
|
140
|
+
"transact"
|
|
141
|
+
],
|
|
142
|
+
"since": "2026-10-11T13:14:55.703Z",
|
|
143
|
+
"at": "2026-10-11T13:14:55.915Z",
|
|
144
|
+
"lifted_at": "2026-10-11T13:14:55.915Z"
|
|
145
|
+
}
|
|
146
|
+
],
|
|
147
|
+
"delta_complete": true,
|
|
148
|
+
"automated_built_at": "2026-10-11T13:14:55.915Z",
|
|
149
|
+
"by_cause": {
|
|
150
|
+
"impersonation_suspected": 2
|
|
151
|
+
},
|
|
152
|
+
"reading": "Domains whose connect or transact decision is block now, for one of three causes: the owner declared an incident (owner_incident), CrawlCheck suspects an impersonation of a top-10,000 brand (impersonation_suspected: connect and transact), or a high-risk field (payment processor, checkout host, wallet, OAuth server, mail host, MCP endpoint) changed and the owner has not confirmed it within 7 days (high_risk_field_changed: transact). Hold this list and refuse those actions offline. With ?since=<generated_at> you get only what was added or changed since (revoked: replace your entry for that domain) and what left the list since (cleared, lifted: remove it); when delta_complete is false the logs no longer reach back that far, so fetch the full list.",
|
|
153
|
+
"poll_seconds": 60,
|
|
154
|
+
"rebuilt": "owner incidents within a minute; automated entries every batch window (:02, :32, :47 UTC)",
|
|
155
|
+
"sdk": "syncRevocations() / sync_revocations() hold it; guard() refuses a listed domain for a listed action with no network call",
|
|
156
|
+
"code": {
|
|
157
|
+
"version_id": null,
|
|
158
|
+
"script_sha16": null,
|
|
159
|
+
"state": "unknown",
|
|
160
|
+
"deployed": null,
|
|
161
|
+
"build_log_entry": null,
|
|
162
|
+
"score_version": 31,
|
|
163
|
+
"scoring_policy_sha256": "86898d2a8c88d6b56e6710f3aadf4375cbb5342d4a2f9aa017b536d95602d671",
|
|
164
|
+
"decision_rules_sha256": "18dd5636e0896692ab518050cb401744edea2e6f1086dff94fead314959bdc3e",
|
|
165
|
+
"check": "https://crawlcheck.io/api/v1/builds"
|
|
166
|
+
},
|
|
167
|
+
"sha256": "1ec217b19ac2ccfebff6cc03a3d0ed8745d77b90ef27698b65b819421c552a76",
|
|
168
|
+
"signature": {
|
|
169
|
+
"v": 1,
|
|
170
|
+
"alg": "Ed25519",
|
|
171
|
+
"kid": "m3JoWSDhN2k3t9MQL11mlWNGm5O5JEJ5dE8ekCIP5ss",
|
|
172
|
+
"over": "sha256",
|
|
173
|
+
"message": "crawlcheck-data-v1\n1ec217b19ac2ccfebff6cc03a3d0ed8745d77b90ef27698b65b819421c552a76",
|
|
174
|
+
"sig": "dh_oCZPp7-uM1xPVxJLTwOx_Spo5kWxGfZjCnst0OPm6v3-iYG2QnAm9MfwkaH-zxF1tiL0UD_NcJ6Pd2F0vCg",
|
|
175
|
+
"signed_at": "2026-10-11T13:14:55.918Z",
|
|
176
|
+
"key": {
|
|
177
|
+
"kid": "m3JoWSDhN2k3t9MQL11mlWNGm5O5JEJ5dE8ekCIP5ss",
|
|
178
|
+
"jwk": {
|
|
179
|
+
"kty": "OKP",
|
|
180
|
+
"crv": "Ed25519",
|
|
181
|
+
"x": "3MSOmtiA71yc0dqVVi2S6abJYKX65mFsosURtK0B0c0"
|
|
182
|
+
},
|
|
183
|
+
"published_in": "https://crawlcheck.io/.well-known/http-message-signatures-directory"
|
|
184
|
+
},
|
|
185
|
+
"canonical": "sha256 over canonical JSON of every field except sha256 and signature"
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
"resolve": {
|
|
189
|
+
"kind": "crawlcheck-resolve",
|
|
190
|
+
"v": 1,
|
|
191
|
+
"schema": "https://crawlcheck.io/schemas/resolve.json",
|
|
192
|
+
"domain": "example-shop.com",
|
|
193
|
+
"answered_at": "2026-10-11T13:14:55.930Z",
|
|
194
|
+
"reading": "Each section is declared (what the site says about itself), observed (what CrawlCheck was served) or not_measured, with when it was observed. There is no overall score: decide on the section your action depends on.",
|
|
195
|
+
"impersonation": {
|
|
196
|
+
"state": "unread",
|
|
197
|
+
"why": "the impersonation check could not be read just now (fetch failed)"
|
|
198
|
+
},
|
|
199
|
+
"status": "partial",
|
|
200
|
+
"basis": {
|
|
201
|
+
"kind": "just_in_time",
|
|
202
|
+
"measured_at": "2026-10-11T13:14:55.932Z",
|
|
203
|
+
"budget_ms": 1000,
|
|
204
|
+
"took_ms": 30,
|
|
205
|
+
"not_read_in_budget": [],
|
|
206
|
+
"scope": "read live the moment this domain was first asked about: DNS, the TLS connection, the homepage as a browser and 2 AI identities, robots.txt, the sitemap and 6 machine files, all inside one 1000 ms budget. Anything listed in not_read_in_budget was not read and is not reported absent. No findings, no grade, no capability invoked."
|
|
207
|
+
},
|
|
208
|
+
"subject": {
|
|
209
|
+
"domain": "example-shop.com",
|
|
210
|
+
"host": "example-shop.com"
|
|
211
|
+
},
|
|
212
|
+
"freshness": {
|
|
213
|
+
"observed_at": "2026-10-11T13:14:55.932Z",
|
|
214
|
+
"age_hours": 0,
|
|
215
|
+
"source": "just_in_time",
|
|
216
|
+
"last_verified_at": "2026-10-11T13:14:55.932Z",
|
|
217
|
+
"fresh_until": "2026-10-18T13:14:55.932Z",
|
|
218
|
+
"stale": false,
|
|
219
|
+
"fresh_policy": "an answer is fresh for 168 hours after observed_at; each action below has its own maximum age. last_verified_at is the latest reading of this domain by any enrolled observer."
|
|
220
|
+
},
|
|
221
|
+
"observers": {
|
|
222
|
+
"primary": {
|
|
223
|
+
"version_tag": null,
|
|
224
|
+
"vantage": null
|
|
225
|
+
},
|
|
226
|
+
"independent_readings": 0,
|
|
227
|
+
"by_field": "https://crawlcheck.io/api/corroboration?domain=example-shop.com",
|
|
228
|
+
"directory": "https://crawlcheck.io/.well-known/crawlcheck-observers.json",
|
|
229
|
+
"samples_by_network": {
|
|
230
|
+
"networks": 0,
|
|
231
|
+
"samples": 0,
|
|
232
|
+
"groups": [],
|
|
233
|
+
"window": "the latest 12 independent readings kept for this domain; the primary scan (Cloudflare) is not counted here"
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
"crawl_policy": {
|
|
237
|
+
"state": "not_measured",
|
|
238
|
+
"observed_at": "2026-10-11T13:14:55.932Z",
|
|
239
|
+
"why": "robots.txt was not read"
|
|
240
|
+
},
|
|
241
|
+
"delivery": {
|
|
242
|
+
"state": "observed",
|
|
243
|
+
"observed_at": "2026-10-11T13:14:55.932Z",
|
|
244
|
+
"url": "https://example-shop.com/",
|
|
245
|
+
"identity_basis": "every identity was sent by CrawlCheck from its own network; none is the named operator's own crawler",
|
|
246
|
+
"identities": [
|
|
247
|
+
{
|
|
248
|
+
"identity": "browser",
|
|
249
|
+
"label": "Mobile browser",
|
|
250
|
+
"status": null,
|
|
251
|
+
"bytes": null,
|
|
252
|
+
"html": false,
|
|
253
|
+
"final_host": null,
|
|
254
|
+
"sha256": null
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
"identity": "gptbot",
|
|
258
|
+
"label": "GPTBot",
|
|
259
|
+
"status": null,
|
|
260
|
+
"bytes": null,
|
|
261
|
+
"html": false,
|
|
262
|
+
"final_host": null,
|
|
263
|
+
"sha256": null
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
"identity": "claudebot",
|
|
267
|
+
"label": "ClaudeBot",
|
|
268
|
+
"status": null,
|
|
269
|
+
"bytes": null,
|
|
270
|
+
"html": false,
|
|
271
|
+
"final_host": null,
|
|
272
|
+
"sha256": null
|
|
273
|
+
}
|
|
274
|
+
],
|
|
275
|
+
"same_bytes_for_every_identity": false
|
|
276
|
+
},
|
|
277
|
+
"machine_files": {
|
|
278
|
+
"state": "not_measured",
|
|
279
|
+
"observed_at": "2026-10-11T13:14:55.932Z",
|
|
280
|
+
"why": "no machine file was read"
|
|
281
|
+
},
|
|
282
|
+
"capabilities": {
|
|
283
|
+
"state": "observed",
|
|
284
|
+
"observed_at": "2026-10-11T13:14:55.932Z",
|
|
285
|
+
"declared": 0,
|
|
286
|
+
"by_safety_class": {},
|
|
287
|
+
"verified": 0,
|
|
288
|
+
"mismatches": 0,
|
|
289
|
+
"invocation_rule": "capabilities are read from declarations only; nothing is invoked. Later verification may call read_only_public operations alone; every other class needs the site owner's authorisation and a test account, and unknown is never called",
|
|
290
|
+
"certificate": null,
|
|
291
|
+
"items": null,
|
|
292
|
+
"declared_files": [],
|
|
293
|
+
"note": "the census records whether a capability file is present; nothing was invoked or verified. A full scan verifies declared capabilities."
|
|
294
|
+
},
|
|
295
|
+
"entity": {
|
|
296
|
+
"state": "not_measured",
|
|
297
|
+
"observed_at": "2026-10-11T13:14:55.932Z",
|
|
298
|
+
"why": "the census carries no entity reading"
|
|
299
|
+
},
|
|
300
|
+
"findings": {
|
|
301
|
+
"access": "public",
|
|
302
|
+
"rule": null,
|
|
303
|
+
"items": [],
|
|
304
|
+
"summary": null,
|
|
305
|
+
"withheld": null,
|
|
306
|
+
"note": "a census record carries readings, not findings"
|
|
307
|
+
},
|
|
308
|
+
"confidence": {
|
|
309
|
+
"identities_answered": 0,
|
|
310
|
+
"vantages": 1,
|
|
311
|
+
"note": "one vantage, one pass, read under a time budget"
|
|
312
|
+
},
|
|
313
|
+
"contradictions": null,
|
|
314
|
+
"evidence": {
|
|
315
|
+
"scan": "POST https://crawlcheck.io/api/scan {\"domain\":\"example-shop.com\"}",
|
|
316
|
+
"offline_verifier": "https://crawlcheck.io/crawlcheck-verify.mjs?v=454ff4ba84bef41b",
|
|
317
|
+
"verify_this_answer": "save it as answer.json, then: node crawlcheck-verify.mjs answer.json"
|
|
318
|
+
},
|
|
319
|
+
"assurance": "degraded",
|
|
320
|
+
"dns": {
|
|
321
|
+
"state": "not_measured",
|
|
322
|
+
"observed_at": "2026-10-11T13:14:55.932Z",
|
|
323
|
+
"why": "the DNS lookup did not answer within the budget (fetch failed)"
|
|
324
|
+
},
|
|
325
|
+
"tls": {
|
|
326
|
+
"state": "observed",
|
|
327
|
+
"observed_at": "2026-10-11T13:14:55.932Z",
|
|
328
|
+
"https": false,
|
|
329
|
+
"url": "https://example-shop.com/",
|
|
330
|
+
"error": "fetch failed",
|
|
331
|
+
"certificate": "the HTTPS connection failed: fetch failed"
|
|
332
|
+
},
|
|
333
|
+
"upgrade": {
|
|
334
|
+
"state": "queued",
|
|
335
|
+
"to": "full",
|
|
336
|
+
"expected": "at the next cron window (:02, :17, :32 or :47 UTC), usually within 30 minutes",
|
|
337
|
+
"how": "a full scan is queued; when it lands this answer is replaced and the change appears on https://crawlcheck.io/api/v1/changes?domain=example-shop.com as section assurance, partial → full",
|
|
338
|
+
"scan_now": "POST https://crawlcheck.io/api/scan {\"domain\":\"example-shop.com\"}"
|
|
339
|
+
},
|
|
340
|
+
"actions": {
|
|
341
|
+
"v": 1,
|
|
342
|
+
"decisions": "allow | warn | require_confirmation | block | unsupported",
|
|
343
|
+
"rule": "decided from this answer's own sections at signing time; an agent's own policy may be stricter, never looser. This is a partial (just-in-time) answer: only what it read live counts, so cite, connect and transact cannot reach their assurance until the full scan lands",
|
|
344
|
+
"read": {
|
|
345
|
+
"decision": "warn",
|
|
346
|
+
"minimum_assurance": "reachable",
|
|
347
|
+
"assurance_found": [],
|
|
348
|
+
"needs": "robots.txt lets AI agents read the path, and the page was served to at least one AI-crawler identity",
|
|
349
|
+
"why": "no robots.txt policy was read, so permission is unknown; the impersonation check could not be read",
|
|
350
|
+
"doubts": [
|
|
351
|
+
"no_policy",
|
|
352
|
+
"impersonation_unread"
|
|
353
|
+
],
|
|
354
|
+
"max_age_hours": 168
|
|
355
|
+
},
|
|
356
|
+
"cite": {
|
|
357
|
+
"decision": "warn",
|
|
358
|
+
"minimum_assurance": "reachable + entity observed",
|
|
359
|
+
"assurance_found": [],
|
|
360
|
+
"needs": "everything read needs, plus an observed entity and no contradiction the site makes with itself",
|
|
361
|
+
"scope": {
|
|
362
|
+
"covers": [
|
|
363
|
+
"access: robots.txt and what was actually served to AI-crawler identities",
|
|
364
|
+
"permissions: the rights and content signals the site declares",
|
|
365
|
+
"identity: the entity the site declares and who has proven control of the domain",
|
|
366
|
+
"provenance: when, from where and by whom it was observed, signed and logged"
|
|
367
|
+
],
|
|
368
|
+
"does_not_cover": "whether the content is true, accurate, current or complete; a cite decision says the source may be cited and attributed, not that what it says is right"
|
|
369
|
+
},
|
|
370
|
+
"why": "no robots.txt policy was read, so permission is unknown; the publisher's identity was not observed, so attribution is unconfirmed; the impersonation check could not be read",
|
|
371
|
+
"doubts": [
|
|
372
|
+
"no_policy",
|
|
373
|
+
"entity_unobserved",
|
|
374
|
+
"read_equivalence_unmeasured",
|
|
375
|
+
"impersonation_unread"
|
|
376
|
+
],
|
|
377
|
+
"max_age_hours": 168
|
|
378
|
+
},
|
|
379
|
+
"connect": {
|
|
380
|
+
"decision": "unsupported",
|
|
381
|
+
"minimum_assurance": "conformant",
|
|
382
|
+
"assurance_found": [],
|
|
383
|
+
"needs": "a declared capability that CrawlCheck invoked read-only and found to behave as declared",
|
|
384
|
+
"why": "the site declares no capability to connect to",
|
|
385
|
+
"doubts": [],
|
|
386
|
+
"max_age_hours": 72
|
|
387
|
+
},
|
|
388
|
+
"transact": {
|
|
389
|
+
"decision": "require_confirmation",
|
|
390
|
+
"minimum_assurance": "action_ready",
|
|
391
|
+
"assurance_found": [],
|
|
392
|
+
"needs": "owner-verified identity and an action-ready capability; CrawlCheck does not certify transactions, so a human confirms",
|
|
393
|
+
"why": "CrawlCheck does not certify transactions; a human confirms every financial, identity or irreversible action; the impersonation check could not be read",
|
|
394
|
+
"doubts": [
|
|
395
|
+
"impersonation_unread"
|
|
396
|
+
],
|
|
397
|
+
"max_age_hours": 24
|
|
398
|
+
},
|
|
399
|
+
"administer": {
|
|
400
|
+
"decision": "block",
|
|
401
|
+
"minimum_assurance": "never automatic",
|
|
402
|
+
"assurance_found": [],
|
|
403
|
+
"needs": "high-privilege operations are never authorised by a public record",
|
|
404
|
+
"why": "never authorised by a public record",
|
|
405
|
+
"doubts": [],
|
|
406
|
+
"max_age_hours": 0
|
|
407
|
+
}
|
|
408
|
+
},
|
|
409
|
+
"absences": [],
|
|
410
|
+
"control": {
|
|
411
|
+
"state": "unproven",
|
|
412
|
+
"proofs": [],
|
|
413
|
+
"organization": null,
|
|
414
|
+
"claim": "https://crawlcheck.io/registry/example-shop.com/claim",
|
|
415
|
+
"why": "nobody has proven control of example-shop.com to CrawlCheck, so who stands behind the site is unconfirmed",
|
|
416
|
+
"note": "control says who answers for the domain, not whether it is safe; it does not change any decision in this answer"
|
|
417
|
+
},
|
|
418
|
+
"transparency": {
|
|
419
|
+
"state": "pending",
|
|
420
|
+
"log_id": "crawlcheck-resolve-log-v1",
|
|
421
|
+
"leaf_hash": "b9d19de859df75cf0147379e15a08fdf227082104a4198073150d9d15a0f079b",
|
|
422
|
+
"queued_at": "2026-10-11T13:14:55.965Z",
|
|
423
|
+
"merged_within_minutes": 30,
|
|
424
|
+
"proof": "https://crawlcheck.io/api/v1/log/proof?leaf=b9d19de859df75cf0147379e15a08fdf227082104a4198073150d9d15a0f079b"
|
|
425
|
+
},
|
|
426
|
+
"degraded": {
|
|
427
|
+
"sources": [
|
|
428
|
+
{
|
|
429
|
+
"source": "impersonation",
|
|
430
|
+
"why": "the impersonation check could not be read just now (fetch failed)"
|
|
431
|
+
}
|
|
432
|
+
],
|
|
433
|
+
"reading": "These sources could not be read when this answer was made. Everything else in it was read as usual; each action that depends on a missing source is decided by the failure policy (fail closed where the missing source could hold a block). Ask again shortly for a complete answer.",
|
|
434
|
+
"policy": "https://crawlcheck.io/spec/failure-policy",
|
|
435
|
+
"policy_v": 1
|
|
436
|
+
},
|
|
437
|
+
"code": {
|
|
438
|
+
"version_id": null,
|
|
439
|
+
"script_sha16": null,
|
|
440
|
+
"state": "unknown",
|
|
441
|
+
"deployed": null,
|
|
442
|
+
"build_log_entry": null,
|
|
443
|
+
"score_version": 31,
|
|
444
|
+
"scoring_policy_sha256": "86898d2a8c88d6b56e6710f3aadf4375cbb5342d4a2f9aa017b536d95602d671",
|
|
445
|
+
"decision_rules_sha256": "18dd5636e0896692ab518050cb401744edea2e6f1086dff94fead314959bdc3e",
|
|
446
|
+
"check": "https://crawlcheck.io/api/v1/builds"
|
|
447
|
+
},
|
|
448
|
+
"sha256": "dec5472e7a7c0445fe70b3b0232807cb9cb87effc1d20f1275a981b70607403d",
|
|
449
|
+
"signature": {
|
|
450
|
+
"v": 1,
|
|
451
|
+
"alg": "Ed25519",
|
|
452
|
+
"kid": "m3JoWSDhN2k3t9MQL11mlWNGm5O5JEJ5dE8ekCIP5ss",
|
|
453
|
+
"over": "sha256",
|
|
454
|
+
"message": "crawlcheck-data-v1\ndec5472e7a7c0445fe70b3b0232807cb9cb87effc1d20f1275a981b70607403d",
|
|
455
|
+
"sig": "0Q1u0ObSS2AzsH0O_enyJQfFK8XrXXUf4xZet1RHuUKF91p3ImkQd7G81LCF8BgfUxFTQd4_Rhibt5RUJq_GAw",
|
|
456
|
+
"signed_at": "2026-10-11T13:14:55.966Z",
|
|
457
|
+
"key": {
|
|
458
|
+
"kid": "m3JoWSDhN2k3t9MQL11mlWNGm5O5JEJ5dE8ekCIP5ss",
|
|
459
|
+
"jwk": {
|
|
460
|
+
"kty": "OKP",
|
|
461
|
+
"crv": "Ed25519",
|
|
462
|
+
"x": "3MSOmtiA71yc0dqVVi2S6abJYKX65mFsosURtK0B0c0"
|
|
463
|
+
},
|
|
464
|
+
"published_in": "https://crawlcheck.io/.well-known/http-message-signatures-directory"
|
|
465
|
+
},
|
|
466
|
+
"canonical": "sha256 over canonical JSON of every field except sha256 and signature"
|
|
467
|
+
}
|
|
468
|
+
},
|
|
469
|
+
"directory": {
|
|
470
|
+
"keys": [
|
|
471
|
+
{
|
|
472
|
+
"kty": "OKP",
|
|
473
|
+
"crv": "Ed25519",
|
|
474
|
+
"x": "3MSOmtiA71yc0dqVVi2S6abJYKX65mFsosURtK0B0c0",
|
|
475
|
+
"kid": "m3JoWSDhN2k3t9MQL11mlWNGm5O5JEJ5dE8ekCIP5ss",
|
|
476
|
+
"use": "sig",
|
|
477
|
+
"alg": "EdDSA",
|
|
478
|
+
"crawlcheck_purpose": "requests and documents",
|
|
479
|
+
"nbf": 1791724495
|
|
480
|
+
}
|
|
481
|
+
]
|
|
482
|
+
}
|
|
483
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Offline revocations (rvd1): the client syncs the signed list and its ?since= delta, and guard() refuses a listed
|
|
2
|
+
// domain for a listed action with the network cut. Fixtures are signed by the Worker's own code with a throwaway key.
|
|
3
|
+
import test from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs";
|
|
4
|
+
import { CrawlCheck, newRevocationStore, applyRevocations, revokedFor } from "../dist/index.js";
|
|
5
|
+
const F = JSON.parse(fs.readFileSync(new URL("./fixtures/revocations.json", import.meta.url), "utf8"));
|
|
6
|
+
|
|
7
|
+
function fakeFetch(state) {
|
|
8
|
+
return async (u) => {
|
|
9
|
+
const url = new URL(String(u));
|
|
10
|
+
if (state.cut) throw new TypeError("fetch failed (network cut)");
|
|
11
|
+
state.calls.push(url.pathname + url.search);
|
|
12
|
+
const body = url.pathname === "/.well-known/http-message-signatures-directory" ? F.directory
|
|
13
|
+
: url.pathname === "/api/v1/revocations" ? (url.searchParams.get("since") ? (state.delta || F.delta) : F.full) : null;
|
|
14
|
+
return new Response(body ? JSON.stringify(body) : "{}", { status: body ? 200 : 404, headers: { "content-type": "application/json" } });
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test("sync: the full list, then the delta (added, lifted), then guard refuses offline", async () => {
|
|
19
|
+
const state = { calls: [], cut: false };
|
|
20
|
+
const cc = new CrawlCheck({ fetch: fakeFetch(state) });
|
|
21
|
+
const a = await cc.syncRevocations();
|
|
22
|
+
assert.equal(a.applied, true, a.reason); assert.equal(a.full, true); assert.equal(a.total, 2);
|
|
23
|
+
assert.ok(revokedFor(cc.revocations, "shop.example", "transact"));
|
|
24
|
+
assert.equal(revokedFor(cc.revocations, "shop.example", "connect"), null, "a high-risk field change revokes transact only");
|
|
25
|
+
const b = await cc.syncRevocations();
|
|
26
|
+
assert.equal(b.applied, true, b.reason); assert.equal(b.full, false); assert.equal(b.added, 1); assert.equal(b.removed, 1);
|
|
27
|
+
assert.ok(state.calls.some((c) => c.startsWith("/api/v1/revocations?since=")), "the second sync asked for a delta");
|
|
28
|
+
assert.equal(revokedFor(cc.revocations, "shop.example", "transact"), null, "lifted");
|
|
29
|
+
assert.ok(revokedFor(cc.revocations, "paypa1.com", "connect"));
|
|
30
|
+
state.cut = true; const n = state.calls.length;
|
|
31
|
+
const g = await cc.guard("checkout.paypa1.com", "transact");
|
|
32
|
+
assert.equal(g.proceed, false); assert.equal(g.revoked.domain, "paypa1.com"); assert.match(g.why[0], /revocation list/);
|
|
33
|
+
const g2 = await cc.guard("xn--pple-43d.com", "connect");
|
|
34
|
+
assert.equal(g2.proceed, false); assert.deepEqual(g2.revoked.causes, ["impersonation_suspected"]);
|
|
35
|
+
assert.equal(state.calls.length, n, "no request was made for a revoked domain");
|
|
36
|
+
const g3 = await cc.guard("paypa1.com", "read");
|
|
37
|
+
assert.equal(g3.proceed, false); assert.equal(g3.revoked, undefined, "read is never revoked by the list; with the network cut guard still fails closed");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("a tampered list is never applied", async () => {
|
|
41
|
+
const bad = JSON.parse(JSON.stringify(F.full)); bad.revoked = [];
|
|
42
|
+
const state = { calls: [], cut: false };
|
|
43
|
+
const cc = new CrawlCheck({ fetch: async (u) => { const p = new URL(String(u)).pathname; return new Response(JSON.stringify(p === "/api/v1/revocations" ? bad : F.directory), { status: 200 }); } });
|
|
44
|
+
const r = await cc.syncRevocations();
|
|
45
|
+
assert.equal(r.applied, false); assert.match(r.reason, /did not verify/); assert.equal(r.total, 0);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("a delta that does not reach back is refused; a store persists as JSON", () => {
|
|
49
|
+
const st = newRevocationStore();
|
|
50
|
+
assert.equal(applyRevocations(st, F.full).applied, true);
|
|
51
|
+
const copy = JSON.parse(JSON.stringify(st));
|
|
52
|
+
assert.ok(revokedFor(copy, "www.shop.example", "transact"), "parent-domain and www match");
|
|
53
|
+
const inc = Object.assign({}, F.delta, { delta_complete: false });
|
|
54
|
+
assert.equal(applyRevocations(copy, inc).applied, false);
|
|
55
|
+
const late = Object.assign({}, F.delta, { since: "2999-01-01T00:00:00.000Z", generated_at: "2999-01-02T00:00:00.000Z" });
|
|
56
|
+
assert.match(applyRevocations(copy, late).reason, /starts after/);
|
|
57
|
+
const old = Object.assign({}, F.full, { generated_at: "2000-01-01T00:00:00.000Z" });
|
|
58
|
+
assert.match(applyRevocations(copy, old).reason, /older/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("private mode: resolve sends only a hash prefix, verifies the bucket's answer, and never names the domain", async () => {
|
|
62
|
+
const crypto = await import("node:crypto");
|
|
63
|
+
const h = crypto.createHash("sha256").update("example-shop.com").digest("hex");
|
|
64
|
+
const calls = [];
|
|
65
|
+
const bucket = { kind: "crawlcheck-resolve-prefix", prefix: h.slice(0, 2), in_bucket: 2, answers: [{ domain_sha256: "0".repeat(64), answer: {} }, { domain_sha256: h, answer: F.resolve }] };
|
|
66
|
+
const cc = new CrawlCheck({ privateLookups: true, fetch: async (u) => { const p = new URL(String(u)); calls.push(p.pathname + p.search);
|
|
67
|
+
const b = p.pathname === "/.well-known/http-message-signatures-directory" ? F.directory : p.pathname.startsWith("/api/v1/resolve-prefix/") ? bucket : null;
|
|
68
|
+
return new Response(JSON.stringify(b || {}), { status: b ? 200 : 404 }); } });
|
|
69
|
+
const a = await cc.resolve("example-shop.com");
|
|
70
|
+
assert.equal(a.domain, "example-shop.com");
|
|
71
|
+
assert.ok(calls.includes("/api/v1/resolve-prefix/" + h.slice(0, 2)));
|
|
72
|
+
assert.ok(calls.every((c) => !c.includes("example-shop")), "the domain never left the process: " + calls.join(" "));
|
|
73
|
+
const v = await cc.resolveFor("example-shop.com");
|
|
74
|
+
assert.equal(v.source, "network"); assert.ok(v.decisions.read);
|
|
75
|
+
await assert.rejects(cc.resolve("not-in-bucket.com"), (e) => e.status === 404 && /never sent/.test(e.message));
|
|
76
|
+
assert.ok(calls.every((c) => !c.includes("not-in-bucket")));
|
|
77
|
+
});
|