@nifrajs/link-safety 0.1.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/dist/corpus.d.ts +7 -0
- package/dist/corpus.d.ts.map +1 -0
- package/dist/corpus.js +167 -0
- package/dist/corpus.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/normalize.d.ts +10 -0
- package/dist/normalize.d.ts.map +1 -0
- package/dist/normalize.js +108 -0
- package/dist/normalize.js.map +1 -0
- package/dist/scan.d.ts +6 -0
- package/dist/scan.d.ts.map +1 -0
- package/dist/scan.js +299 -0
- package/dist/scan.js.map +1 -0
- package/dist/types.d.ts +112 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +37 -0
- package/dist/types.js.map +1 -0
- package/package.json +41 -0
- package/src/corpus.ts +210 -0
- package/src/index.ts +35 -0
- package/src/normalize.ts +118 -0
- package/src/scan.ts +405 -0
- package/src/types.ts +175 -0
package/src/scan.ts
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import { normalizeLink } from "./normalize.js";
|
|
2
|
+
import type {
|
|
3
|
+
CorpusEntry,
|
|
4
|
+
CorpusMatch,
|
|
5
|
+
LinkClassifier,
|
|
6
|
+
LinkClassifierResult,
|
|
7
|
+
LinkSafetyErrorCode,
|
|
8
|
+
LinkSafetyPolicy,
|
|
9
|
+
LinkSafetyReason,
|
|
10
|
+
LinkSafetyVerdict,
|
|
11
|
+
LinkScanEvidence,
|
|
12
|
+
LinkScanFailure,
|
|
13
|
+
LinkScanOptions,
|
|
14
|
+
LinkScanResult,
|
|
15
|
+
LinkScanSuccess,
|
|
16
|
+
NormalizedLink,
|
|
17
|
+
} from "./types.js";
|
|
18
|
+
import { LINK_SAFETY_ERROR_CODES, LINK_SAFETY_REASONS } from "./types.js";
|
|
19
|
+
|
|
20
|
+
const DEFAULT_MINIMUM_CONFIDENCE = 0.92;
|
|
21
|
+
const ERROR_CODE_SET = new Set<string>(LINK_SAFETY_ERROR_CODES);
|
|
22
|
+
const REASON_SET = new Set<string>(LINK_SAFETY_REASONS);
|
|
23
|
+
|
|
24
|
+
export function createLinkSafetyScanner(defaults: LinkScanOptions = {}): {
|
|
25
|
+
scan(input: unknown, options?: LinkScanOptions): Promise<LinkScanResult>;
|
|
26
|
+
} {
|
|
27
|
+
return Object.freeze({
|
|
28
|
+
scan: (input: unknown, options: LinkScanOptions = {}) =>
|
|
29
|
+
scanLink(input, mergeOptions(defaults, options)),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function scanLink(
|
|
34
|
+
input: unknown,
|
|
35
|
+
options: LinkScanOptions = {},
|
|
36
|
+
): Promise<LinkScanResult> {
|
|
37
|
+
const startedAt = Date.now();
|
|
38
|
+
const normalized = normalizeLink(input);
|
|
39
|
+
if (!normalized.ok) return failure(normalized.error, startedAt);
|
|
40
|
+
const signal = options.signal ?? new AbortController().signal;
|
|
41
|
+
if (signal.aborted) return failure("cancelled", startedAt, normalized.link);
|
|
42
|
+
|
|
43
|
+
const policyResult = validatePolicy(options.policy);
|
|
44
|
+
if (!policyResult.ok) return failure("policy_invalid", startedAt, normalized.link);
|
|
45
|
+
const policy = policyResult.policy;
|
|
46
|
+
|
|
47
|
+
let matches: readonly CorpusMatch[] = [];
|
|
48
|
+
let corpusVersion: string | undefined;
|
|
49
|
+
if (options.corpus !== undefined) {
|
|
50
|
+
if (!isValidCorpus(options.corpus)) {
|
|
51
|
+
return failure("corpus_invalid", startedAt, normalized.link);
|
|
52
|
+
}
|
|
53
|
+
corpusVersion = options.corpus.version;
|
|
54
|
+
try {
|
|
55
|
+
matches = await options.corpus.lookup(normalized.link);
|
|
56
|
+
} catch {
|
|
57
|
+
return failure("corpus_unavailable", startedAt, normalized.link);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (signal.aborted) return failure("cancelled", startedAt, normalized.link);
|
|
61
|
+
|
|
62
|
+
const deterministic = deterministicClassification(normalized.link, matches);
|
|
63
|
+
const hardMalicious = matches.some((match) => match.entry.label === "malicious");
|
|
64
|
+
let semantic: LinkClassifierResult | undefined;
|
|
65
|
+
let examples: readonly CorpusEntry[] = [];
|
|
66
|
+
if (options.classifier !== undefined && !hardMalicious) {
|
|
67
|
+
if (!isValidClassifier(options.classifier)) {
|
|
68
|
+
return failure("classifier_response_invalid", startedAt, normalized.link);
|
|
69
|
+
}
|
|
70
|
+
if (options.corpus?.examples !== undefined) {
|
|
71
|
+
try {
|
|
72
|
+
examples = await options.corpus.examples(normalized.link, { limit: 8 });
|
|
73
|
+
} catch {
|
|
74
|
+
return failure("corpus_unavailable", startedAt, normalized.link);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
semantic = validateClassifierResult(
|
|
79
|
+
await options.classifier.classify({
|
|
80
|
+
link: normalized.link,
|
|
81
|
+
matches,
|
|
82
|
+
examples,
|
|
83
|
+
signal,
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
} catch {
|
|
87
|
+
return failure(
|
|
88
|
+
signal.aborted ? "cancelled" : "classifier_unavailable",
|
|
89
|
+
startedAt,
|
|
90
|
+
normalized.link,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
if (!semantic.ok) return failure(semantic.error, startedAt, normalized.link);
|
|
94
|
+
}
|
|
95
|
+
if (signal.aborted) return failure("cancelled", startedAt, normalized.link);
|
|
96
|
+
|
|
97
|
+
return combineClassification(
|
|
98
|
+
normalized.link,
|
|
99
|
+
matches,
|
|
100
|
+
semantic?.ok === true ? semantic : undefined,
|
|
101
|
+
deterministic,
|
|
102
|
+
policy,
|
|
103
|
+
corpusVersion,
|
|
104
|
+
options.classifier?.id,
|
|
105
|
+
startedAt,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface DeterministicClassification {
|
|
110
|
+
readonly verdict: LinkSafetyVerdict;
|
|
111
|
+
readonly riskScore: number;
|
|
112
|
+
readonly confidence: number;
|
|
113
|
+
readonly reasons: readonly LinkSafetyReason[];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function combineClassification(
|
|
117
|
+
link: NormalizedLink,
|
|
118
|
+
matches: readonly CorpusMatch[],
|
|
119
|
+
semantic: Extract<LinkClassifierResult, { ok: true }> | undefined,
|
|
120
|
+
deterministic: DeterministicClassification,
|
|
121
|
+
policy: Required<LinkSafetyPolicy>,
|
|
122
|
+
corpusVersion: string | undefined,
|
|
123
|
+
classifierId: string | undefined,
|
|
124
|
+
startedAt: number,
|
|
125
|
+
): LinkScanSuccess {
|
|
126
|
+
const labels = new Set(matches.map((match) => match.entry.label));
|
|
127
|
+
const conflict = labels.size > 1;
|
|
128
|
+
const reasons = new Set<LinkSafetyReason>(deterministic.reasons);
|
|
129
|
+
let verdict: LinkSafetyVerdict = deterministic.verdict;
|
|
130
|
+
let confidence = deterministic.confidence;
|
|
131
|
+
let riskScore = deterministic.riskScore;
|
|
132
|
+
let provider: string | undefined;
|
|
133
|
+
let model: string | undefined;
|
|
134
|
+
|
|
135
|
+
if (semantic !== undefined) {
|
|
136
|
+
provider = semantic.provider ?? classifierId;
|
|
137
|
+
model = semantic.model;
|
|
138
|
+
reasons.add("semantic_classifier");
|
|
139
|
+
for (const reason of semantic.reasons ?? []) reasons.add(reason);
|
|
140
|
+
riskScore = Math.max(riskScore, semantic.riskScore);
|
|
141
|
+
confidence = semantic.confidence;
|
|
142
|
+
verdict = semantic.verdict;
|
|
143
|
+
if (deterministic.verdict === "malicious" || conflict) {
|
|
144
|
+
verdict = conflict ? "suspicious" : "malicious";
|
|
145
|
+
} else if (deterministic.riskScore >= 0.65 && semantic.verdict === "benign") {
|
|
146
|
+
verdict = "suspicious";
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (conflict) {
|
|
150
|
+
reasons.add("corpus_conflict");
|
|
151
|
+
verdict = "suspicious";
|
|
152
|
+
confidence = Math.min(confidence, 0.5);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const policyDecision = recommendedAction(
|
|
156
|
+
verdict,
|
|
157
|
+
confidence,
|
|
158
|
+
policy,
|
|
159
|
+
matches,
|
|
160
|
+
conflict,
|
|
161
|
+
);
|
|
162
|
+
const evidence: LinkScanEvidence = Object.freeze({
|
|
163
|
+
...(corpusVersion === undefined ? {} : { corpusVersion }),
|
|
164
|
+
corpusMatchCount: matches.length,
|
|
165
|
+
...(provider === undefined ? {} : { provider }),
|
|
166
|
+
...(model === undefined ? {} : { model }),
|
|
167
|
+
deterministicReasons: Object.freeze([...deterministic.reasons]),
|
|
168
|
+
latencyMs: Math.max(0, Date.now() - startedAt),
|
|
169
|
+
});
|
|
170
|
+
return Object.freeze({
|
|
171
|
+
ok: true,
|
|
172
|
+
link,
|
|
173
|
+
verdict,
|
|
174
|
+
riskScore: clampUnit(riskScore),
|
|
175
|
+
confidence: clampUnit(confidence),
|
|
176
|
+
reasons: Object.freeze([...reasons]),
|
|
177
|
+
recommendedAction: policyDecision.action,
|
|
178
|
+
policyReason: policyDecision.reason,
|
|
179
|
+
evidence,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function deterministicClassification(
|
|
184
|
+
link: NormalizedLink,
|
|
185
|
+
matches: readonly CorpusMatch[],
|
|
186
|
+
): DeterministicClassification {
|
|
187
|
+
const reasons = new Set<LinkSafetyReason>(link.flags);
|
|
188
|
+
const labels = new Set(matches.map((match) => match.entry.label));
|
|
189
|
+
if (labels.has("malicious")) reasons.add("corpus_malicious_match");
|
|
190
|
+
if (labels.has("suspicious")) reasons.add("corpus_suspicious_match");
|
|
191
|
+
if (labels.has("benign")) reasons.add("corpus_benign_match");
|
|
192
|
+
if (labels.size > 1) reasons.add("corpus_conflict");
|
|
193
|
+
|
|
194
|
+
if (labels.size > 1) {
|
|
195
|
+
return {
|
|
196
|
+
verdict: "suspicious",
|
|
197
|
+
riskScore: 0.75,
|
|
198
|
+
confidence: 0.5,
|
|
199
|
+
reasons: [...reasons],
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
if (labels.has("malicious")) {
|
|
203
|
+
return { verdict: "malicious", riskScore: 1, confidence: 1, reasons: [...reasons] };
|
|
204
|
+
}
|
|
205
|
+
if (labels.has("suspicious")) {
|
|
206
|
+
return {
|
|
207
|
+
verdict: "suspicious",
|
|
208
|
+
riskScore: 0.75,
|
|
209
|
+
confidence: 1,
|
|
210
|
+
reasons: [...reasons],
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
if (labels.has("benign") && link.flags.length === 0) {
|
|
214
|
+
return { verdict: "benign", riskScore: 0.05, confidence: 1, reasons: [...reasons] };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const riskScore = heuristicRisk(link);
|
|
218
|
+
return {
|
|
219
|
+
verdict: riskScore >= 0.65 ? "suspicious" : "unknown",
|
|
220
|
+
riskScore,
|
|
221
|
+
confidence: riskScore >= 0.65 ? 0.6 : 0,
|
|
222
|
+
reasons: [...reasons],
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function heuristicRisk(link: NormalizedLink): number {
|
|
227
|
+
const weights: Partial<Record<LinkSafetyReason, number>> = {
|
|
228
|
+
punycode_hostname: 0.3,
|
|
229
|
+
ip_literal: 0.25,
|
|
230
|
+
private_network_candidate: 0.4,
|
|
231
|
+
non_default_port: 0.12,
|
|
232
|
+
long_hostname: 0.12,
|
|
233
|
+
many_subdomains: 0.12,
|
|
234
|
+
encoded_path_separator: 0.3,
|
|
235
|
+
long_path: 0.08,
|
|
236
|
+
};
|
|
237
|
+
return clampUnit(link.flags.reduce((total, flag) => total + (weights[flag] ?? 0), 0));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function recommendedAction(
|
|
241
|
+
verdict: LinkSafetyVerdict,
|
|
242
|
+
confidence: number,
|
|
243
|
+
policy: Required<LinkSafetyPolicy>,
|
|
244
|
+
matches: readonly CorpusMatch[],
|
|
245
|
+
conflict: boolean,
|
|
246
|
+
): {
|
|
247
|
+
readonly action: "allow" | "review" | "block";
|
|
248
|
+
readonly reason: LinkScanSuccess["policyReason"];
|
|
249
|
+
} {
|
|
250
|
+
if (!policy.allowAct) {
|
|
251
|
+
return { action: "review", reason: "automatic_action_not_enabled" };
|
|
252
|
+
}
|
|
253
|
+
if (conflict) return { action: "review", reason: "conflicting_signals" };
|
|
254
|
+
const knownMalicious = matches.some((match) => match.entry.label === "malicious");
|
|
255
|
+
if (
|
|
256
|
+
verdict === "malicious" &&
|
|
257
|
+
(knownMalicious
|
|
258
|
+
? policy.blockKnownMalicious
|
|
259
|
+
: confidence >= policy.minimumConfidence)
|
|
260
|
+
) {
|
|
261
|
+
return { action: "block", reason: "passed" };
|
|
262
|
+
}
|
|
263
|
+
if (verdict === "benign" && confidence >= policy.minimumConfidence) {
|
|
264
|
+
return { action: "allow", reason: "passed" };
|
|
265
|
+
}
|
|
266
|
+
if (verdict === "unknown") return { action: "review", reason: "no_reliable_signal" };
|
|
267
|
+
return { action: "review", reason: "confidence_below_threshold" };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function validatePolicy(
|
|
271
|
+
policy: LinkSafetyPolicy | undefined,
|
|
272
|
+
):
|
|
273
|
+
| { readonly ok: true; readonly policy: Required<LinkSafetyPolicy> }
|
|
274
|
+
| { readonly ok: false } {
|
|
275
|
+
const value = policy ?? {};
|
|
276
|
+
const minimumConfidence = value.minimumConfidence ?? DEFAULT_MINIMUM_CONFIDENCE;
|
|
277
|
+
if (
|
|
278
|
+
typeof minimumConfidence !== "number" ||
|
|
279
|
+
!Number.isFinite(minimumConfidence) ||
|
|
280
|
+
minimumConfidence < 0 ||
|
|
281
|
+
minimumConfidence > 1
|
|
282
|
+
) {
|
|
283
|
+
return { ok: false };
|
|
284
|
+
}
|
|
285
|
+
return {
|
|
286
|
+
ok: true,
|
|
287
|
+
policy: {
|
|
288
|
+
allowAct: value.allowAct === true,
|
|
289
|
+
minimumConfidence,
|
|
290
|
+
blockKnownMalicious: value.blockKnownMalicious !== false,
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function validateClassifierResult(result: unknown): LinkClassifierResult {
|
|
296
|
+
if (!isRecord(result) || typeof result.ok !== "boolean") {
|
|
297
|
+
return { ok: false, error: "classifier_response_invalid" };
|
|
298
|
+
}
|
|
299
|
+
if (!result.ok) {
|
|
300
|
+
return {
|
|
301
|
+
ok: false,
|
|
302
|
+
error: isLinkSafetyErrorCode(result.error)
|
|
303
|
+
? result.error
|
|
304
|
+
: "classifier_response_invalid",
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
if (
|
|
308
|
+
result.verdict !== "benign" &&
|
|
309
|
+
result.verdict !== "suspicious" &&
|
|
310
|
+
result.verdict !== "malicious"
|
|
311
|
+
) {
|
|
312
|
+
return { ok: false, error: "classifier_response_invalid" };
|
|
313
|
+
}
|
|
314
|
+
if (!isUnit(result.riskScore) || !isUnit(result.confidence)) {
|
|
315
|
+
return { ok: false, error: "classifier_response_invalid" };
|
|
316
|
+
}
|
|
317
|
+
const reasons = result.reasons;
|
|
318
|
+
if (
|
|
319
|
+
reasons !== undefined &&
|
|
320
|
+
(!Array.isArray(reasons) ||
|
|
321
|
+
reasons.length > 16 ||
|
|
322
|
+
reasons.some((reason) => !isReason(reason)))
|
|
323
|
+
) {
|
|
324
|
+
return { ok: false, error: "classifier_response_invalid" };
|
|
325
|
+
}
|
|
326
|
+
return {
|
|
327
|
+
ok: true,
|
|
328
|
+
verdict: result.verdict,
|
|
329
|
+
riskScore: result.riskScore,
|
|
330
|
+
confidence: result.confidence,
|
|
331
|
+
...(reasons === undefined ? {} : { reasons: Object.freeze([...reasons]) }),
|
|
332
|
+
...(typeof result.provider === "string" && result.provider.length <= 128
|
|
333
|
+
? { provider: result.provider }
|
|
334
|
+
: {}),
|
|
335
|
+
...(typeof result.model === "string" && result.model.length <= 128
|
|
336
|
+
? { model: result.model }
|
|
337
|
+
: {}),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function isValidCorpus(
|
|
342
|
+
value: unknown,
|
|
343
|
+
): value is NonNullable<LinkScanOptions["corpus"]> {
|
|
344
|
+
return (
|
|
345
|
+
isRecord(value) &&
|
|
346
|
+
typeof value.version === "string" &&
|
|
347
|
+
typeof value.lookup === "function"
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function isValidClassifier(value: unknown): value is LinkClassifier {
|
|
352
|
+
return (
|
|
353
|
+
isRecord(value) &&
|
|
354
|
+
typeof value.id === "string" &&
|
|
355
|
+
value.id.length > 0 &&
|
|
356
|
+
typeof value.classify === "function"
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function mergeOptions(
|
|
361
|
+
defaults: LinkScanOptions,
|
|
362
|
+
options: LinkScanOptions,
|
|
363
|
+
): LinkScanOptions {
|
|
364
|
+
return {
|
|
365
|
+
...defaults,
|
|
366
|
+
...options,
|
|
367
|
+
policy: { ...defaults.policy, ...options.policy },
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function failure(
|
|
372
|
+
error: LinkSafetyErrorCode,
|
|
373
|
+
startedAt: number,
|
|
374
|
+
link?: NormalizedLink,
|
|
375
|
+
): LinkScanFailure {
|
|
376
|
+
return Object.freeze({
|
|
377
|
+
ok: false,
|
|
378
|
+
error,
|
|
379
|
+
recommendedAction: "review",
|
|
380
|
+
...(link === undefined ? {} : { link }),
|
|
381
|
+
latencyMs: Math.max(0, Date.now() - startedAt),
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function isLinkSafetyErrorCode(value: unknown): value is LinkSafetyErrorCode {
|
|
386
|
+
return typeof value === "string" && ERROR_CODE_SET.has(value);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function isReason(value: unknown): value is LinkSafetyReason {
|
|
390
|
+
return typeof value === "string" && REASON_SET.has(value);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function isUnit(value: unknown): value is number {
|
|
394
|
+
return (
|
|
395
|
+
typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function clampUnit(value: number): number {
|
|
400
|
+
return Math.max(0, Math.min(1, Number.isFinite(value) ? value : 0));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
404
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
405
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
export const LINK_SAFETY_VERDICTS = [
|
|
2
|
+
"benign",
|
|
3
|
+
"suspicious",
|
|
4
|
+
"malicious",
|
|
5
|
+
"unknown",
|
|
6
|
+
] as const;
|
|
7
|
+
|
|
8
|
+
export type LinkSafetyVerdict = (typeof LINK_SAFETY_VERDICTS)[number];
|
|
9
|
+
export type ClassifiedLinkVerdict = Exclude<LinkSafetyVerdict, "unknown">;
|
|
10
|
+
|
|
11
|
+
export const LINK_SAFETY_ACTIONS = ["allow", "review", "block"] as const;
|
|
12
|
+
export type LinkSafetyAction = (typeof LINK_SAFETY_ACTIONS)[number];
|
|
13
|
+
|
|
14
|
+
export const LINK_SAFETY_REASONS = [
|
|
15
|
+
"corpus_benign_match",
|
|
16
|
+
"corpus_suspicious_match",
|
|
17
|
+
"corpus_malicious_match",
|
|
18
|
+
"corpus_conflict",
|
|
19
|
+
"punycode_hostname",
|
|
20
|
+
"ip_literal",
|
|
21
|
+
"private_network_candidate",
|
|
22
|
+
"non_default_port",
|
|
23
|
+
"long_hostname",
|
|
24
|
+
"many_subdomains",
|
|
25
|
+
"encoded_path_separator",
|
|
26
|
+
"long_path",
|
|
27
|
+
"query_present",
|
|
28
|
+
"semantic_classifier",
|
|
29
|
+
] as const;
|
|
30
|
+
|
|
31
|
+
export type LinkSafetyReason = (typeof LINK_SAFETY_REASONS)[number];
|
|
32
|
+
|
|
33
|
+
export const LINK_SAFETY_ERROR_CODES = [
|
|
34
|
+
"invalid_url",
|
|
35
|
+
"corpus_invalid",
|
|
36
|
+
"corpus_unavailable",
|
|
37
|
+
"classifier_unavailable",
|
|
38
|
+
"classifier_timeout",
|
|
39
|
+
"classifier_rate_limited",
|
|
40
|
+
"classifier_unauthorized",
|
|
41
|
+
"classifier_response_invalid",
|
|
42
|
+
"cancelled",
|
|
43
|
+
"policy_invalid",
|
|
44
|
+
"internal",
|
|
45
|
+
] as const;
|
|
46
|
+
|
|
47
|
+
export type LinkSafetyErrorCode = (typeof LINK_SAFETY_ERROR_CODES)[number];
|
|
48
|
+
|
|
49
|
+
export type CorpusLabel = Exclude<LinkSafetyVerdict, "unknown">;
|
|
50
|
+
export type CorpusIndicatorType = "hostname" | "hostname_path_prefix" | "url_prefix";
|
|
51
|
+
|
|
52
|
+
export interface NormalizedLink {
|
|
53
|
+
readonly protocol: "http" | "https";
|
|
54
|
+
readonly hostname: string;
|
|
55
|
+
readonly port?: number;
|
|
56
|
+
readonly pathname: string;
|
|
57
|
+
readonly canonical: string;
|
|
58
|
+
readonly hasQuery: boolean;
|
|
59
|
+
readonly queryParameterCount: number;
|
|
60
|
+
readonly queryLength: number;
|
|
61
|
+
readonly flags: readonly LinkSafetyReason[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface CorpusEntry {
|
|
65
|
+
readonly id: string;
|
|
66
|
+
readonly indicatorType: CorpusIndicatorType;
|
|
67
|
+
readonly indicator: string;
|
|
68
|
+
readonly label: CorpusLabel;
|
|
69
|
+
readonly category?: string;
|
|
70
|
+
readonly source?: string;
|
|
71
|
+
readonly updatedAt?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface CorpusMatch {
|
|
75
|
+
readonly entry: CorpusEntry;
|
|
76
|
+
readonly matchType: CorpusIndicatorType;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface CorpusExamplesOptions {
|
|
80
|
+
readonly limit?: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface LinkCorpus {
|
|
84
|
+
readonly version: string;
|
|
85
|
+
lookup(link: NormalizedLink): PromiseLike<readonly CorpusMatch[]>;
|
|
86
|
+
examples?(
|
|
87
|
+
link: NormalizedLink,
|
|
88
|
+
options?: CorpusExamplesOptions,
|
|
89
|
+
): PromiseLike<readonly CorpusEntry[]>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface LinkClassifierInput {
|
|
93
|
+
readonly link: NormalizedLink;
|
|
94
|
+
readonly matches: readonly CorpusMatch[];
|
|
95
|
+
readonly examples: readonly CorpusEntry[];
|
|
96
|
+
readonly signal: AbortSignal;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface LinkClassifierSuccess {
|
|
100
|
+
readonly ok: true;
|
|
101
|
+
readonly verdict: ClassifiedLinkVerdict;
|
|
102
|
+
readonly riskScore: number;
|
|
103
|
+
readonly confidence: number;
|
|
104
|
+
readonly reasons?: readonly LinkSafetyReason[];
|
|
105
|
+
readonly provider?: string;
|
|
106
|
+
readonly model?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface LinkClassifierFailure {
|
|
110
|
+
readonly ok: false;
|
|
111
|
+
readonly error: LinkSafetyErrorCode;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type LinkClassifierResult = LinkClassifierSuccess | LinkClassifierFailure;
|
|
115
|
+
|
|
116
|
+
export interface LinkClassifier {
|
|
117
|
+
readonly id: string;
|
|
118
|
+
classify(input: LinkClassifierInput): PromiseLike<LinkClassifierResult>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface LinkSafetyPolicy {
|
|
122
|
+
/** Automatic allow/block recommendations are disabled unless explicitly true. */
|
|
123
|
+
readonly allowAct?: boolean;
|
|
124
|
+
/** Required confidence for semantic recommendations. Defaults to 0.92. */
|
|
125
|
+
readonly minimumConfidence?: number;
|
|
126
|
+
/** An exact malicious corpus match can recommend block when action is enabled. */
|
|
127
|
+
readonly blockKnownMalicious?: boolean;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface LinkScanOptions {
|
|
131
|
+
readonly corpus?: LinkCorpus;
|
|
132
|
+
readonly classifier?: LinkClassifier;
|
|
133
|
+
readonly policy?: LinkSafetyPolicy;
|
|
134
|
+
readonly signal?: AbortSignal;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface LinkScanEvidence {
|
|
138
|
+
readonly corpusVersion?: string;
|
|
139
|
+
readonly corpusMatchCount: number;
|
|
140
|
+
readonly provider?: string;
|
|
141
|
+
readonly model?: string;
|
|
142
|
+
readonly deterministicReasons: readonly LinkSafetyReason[];
|
|
143
|
+
readonly latencyMs: number;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface LinkScanSuccess {
|
|
147
|
+
readonly ok: true;
|
|
148
|
+
readonly link: NormalizedLink;
|
|
149
|
+
readonly verdict: LinkSafetyVerdict;
|
|
150
|
+
readonly riskScore: number;
|
|
151
|
+
readonly confidence: number;
|
|
152
|
+
readonly reasons: readonly LinkSafetyReason[];
|
|
153
|
+
readonly recommendedAction: LinkSafetyAction;
|
|
154
|
+
readonly policyReason:
|
|
155
|
+
| "automatic_action_not_enabled"
|
|
156
|
+
| "confidence_below_threshold"
|
|
157
|
+
| "conflicting_signals"
|
|
158
|
+
| "no_reliable_signal"
|
|
159
|
+
| "passed";
|
|
160
|
+
readonly evidence: LinkScanEvidence;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface LinkScanFailure {
|
|
164
|
+
readonly ok: false;
|
|
165
|
+
readonly error: LinkSafetyErrorCode;
|
|
166
|
+
readonly recommendedAction: "review";
|
|
167
|
+
readonly link?: NormalizedLink;
|
|
168
|
+
readonly latencyMs: number;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export type LinkScanResult = LinkScanSuccess | LinkScanFailure;
|
|
172
|
+
|
|
173
|
+
export interface LinkSafetyScanner {
|
|
174
|
+
scan(input: unknown, options?: LinkScanOptions): Promise<LinkScanResult>;
|
|
175
|
+
}
|