@llnvd/openclaw-url-guard 0.0.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.
Files changed (83) hide show
  1. package/.forgejo/workflows/ci.yaml +117 -0
  2. package/.husky/pre-commit +1 -0
  3. package/.oxlintrc.json +14 -0
  4. package/.prettierignore +3 -0
  5. package/.prettierrc +7 -0
  6. package/CONTRIBUTING.md +173 -0
  7. package/LICENSE +21 -0
  8. package/README.md +191 -0
  9. package/TASK.md +39 -0
  10. package/dist/package.json +39 -0
  11. package/dist/src/config.d.ts +150 -0
  12. package/dist/src/config.js +206 -0
  13. package/dist/src/filters/matcher.d.ts +6 -0
  14. package/dist/src/filters/matcher.js +111 -0
  15. package/dist/src/filters/urlhaus.d.ts +8 -0
  16. package/dist/src/filters/urlhaus.js +141 -0
  17. package/dist/src/index.d.ts +3 -0
  18. package/dist/src/index.js +16 -0
  19. package/dist/src/scoring/engine.d.ts +2 -0
  20. package/dist/src/scoring/engine.js +118 -0
  21. package/dist/src/tools/safeFetch.d.ts +2 -0
  22. package/dist/src/tools/safeFetch.js +121 -0
  23. package/dist/src/tools/safeSearch.d.ts +2 -0
  24. package/dist/src/tools/safeSearch.js +81 -0
  25. package/dist/src/types.d.ts +99 -0
  26. package/dist/src/types.js +2 -0
  27. package/docs/AGENT-INSTALL.md +118 -0
  28. package/docs/DEPLOY_GIST.md +84 -0
  29. package/docs/README.md +40 -0
  30. package/docs/api-reference.md +274 -0
  31. package/docs/configuration.md +218 -0
  32. package/docs/openclaw-integration.md +155 -0
  33. package/docs/scoring.md +81 -0
  34. package/docs/testing.md +53 -0
  35. package/docs/usage-modes.md +127 -0
  36. package/examples/openclaw-config-avoid-sites.yaml +49 -0
  37. package/examples/openclaw-config-learning-sites.yaml +43 -0
  38. package/examples/openclaw-config-scoring.yaml +43 -0
  39. package/openclaw.plugin.json +66 -0
  40. package/openspec/changes/e2e-real-openclaw-tests/design.md +106 -0
  41. package/openspec/changes/e2e-real-openclaw-tests/proposal.md +35 -0
  42. package/openspec/changes/e2e-real-openclaw-tests/specs/e2e-real-instance/spec.md +197 -0
  43. package/openspec/changes/e2e-real-openclaw-tests/tasks.md +34 -0
  44. package/openspec/config.yaml +8 -0
  45. package/openspec/specs/e2e-real-instance/spec.md +197 -0
  46. package/package.json +39 -0
  47. package/src/config.ts +228 -0
  48. package/src/filters/matcher.ts +126 -0
  49. package/src/filters/urlhaus.ts +170 -0
  50. package/src/index.ts +14 -0
  51. package/src/scoring/engine.ts +144 -0
  52. package/src/tools/safeFetch.ts +163 -0
  53. package/src/tools/safeSearch.ts +108 -0
  54. package/src/types.ts +136 -0
  55. package/tests/e2e/cases/backward-compat.test.ts +22 -0
  56. package/tests/e2e/cases/blocklist-block.test.ts +18 -0
  57. package/tests/e2e/cases/scoring-suspicious.test.ts +30 -0
  58. package/tests/e2e/cases/trusted-bypass.test.ts +31 -0
  59. package/tests/e2e/cases/urlhaus-live.test.ts +33 -0
  60. package/tests/e2e/fixtures/config.ts +60 -0
  61. package/tests/e2e/fixtures/test-urls.ts +5 -0
  62. package/tests/e2e/harness.ts +73 -0
  63. package/tests/integration/allowlist.test.ts +87 -0
  64. package/tests/integration/blocklist.test.ts +48 -0
  65. package/tests/integration/bypass-protection.test.ts +151 -0
  66. package/tests/integration/config-validation.test.ts +55 -0
  67. package/tests/integration/error-handling.test.ts +57 -0
  68. package/tests/integration/fixtures/urlhaus-sample.csv +6 -0
  69. package/tests/integration/helpers/client.ts +185 -0
  70. package/tests/integration/helpers/config.ts +103 -0
  71. package/tests/integration/helpers/gateway.ts +185 -0
  72. package/tests/integration/helpers/test-mode.ts +20 -0
  73. package/tests/integration/hybrid.test.ts +56 -0
  74. package/tests/integration/ssrf.test.ts +61 -0
  75. package/tests/integration/urlhaus.test.ts +56 -0
  76. package/tests/integration.test.ts +72 -0
  77. package/tests/matcher.test.ts +62 -0
  78. package/tests/safeFetch.test.ts +247 -0
  79. package/tests/safeSearch.test.ts +80 -0
  80. package/tests/scoring.test.ts +106 -0
  81. package/tests/security.test.ts +130 -0
  82. package/tests/urlhaus.test.ts +124 -0
  83. package/tsconfig.json +16 -0
package/src/config.ts ADDED
@@ -0,0 +1,228 @@
1
+ import type { UrlGuardConfig } from './types';
2
+
3
+ export const RECOMMENDED_ALLOWLIST = [
4
+ 'docs.python.org',
5
+ 'developer.mozilla.org',
6
+ 'en.wikipedia.org',
7
+ 'github.com',
8
+ 'stackoverflow.com',
9
+ ] as const;
10
+
11
+ const DEFAULT_CONFIG: UrlGuardConfig = {
12
+ mode: 'allowlist',
13
+ allowlist: [],
14
+ blocklist: [],
15
+ blockPrivateIps: true,
16
+ threatFeeds: {
17
+ urlhaus: false,
18
+ mode: 'fail-open',
19
+ },
20
+ scoring: {
21
+ enabled: false,
22
+ defaultScore: 0,
23
+ minScore: -6,
24
+ rules: [],
25
+ feedScores: {
26
+ urlhaus: {
27
+ online: -10,
28
+ offline: -7,
29
+ },
30
+ spamhaus: {
31
+ botnet_cc: -10,
32
+ phishing_domain: -10,
33
+ spammer_domain: -8,
34
+ 'abused_legit_*': -6,
35
+ abused_redirector: -4,
36
+ },
37
+ },
38
+ },
39
+ logging: {
40
+ enabled: false,
41
+ logBlocked: true,
42
+ verboseErrors: false,
43
+ },
44
+ };
45
+
46
+ export const configSchema = {
47
+ type: 'object',
48
+ additionalProperties: false,
49
+ required: ['mode'],
50
+ properties: {
51
+ mode: {
52
+ type: 'string',
53
+ enum: ['allowlist', 'blocklist', 'hybrid'],
54
+ },
55
+ allowlist: {
56
+ type: 'array',
57
+ items: { type: 'string' },
58
+ },
59
+ blocklist: {
60
+ type: 'array',
61
+ items: { type: 'string' },
62
+ },
63
+ blockPrivateIps: {
64
+ type: 'boolean',
65
+ },
66
+ threatFeeds: {
67
+ type: 'object',
68
+ additionalProperties: false,
69
+ properties: {
70
+ urlhaus: { type: 'boolean' },
71
+ mode: {
72
+ type: 'string',
73
+ enum: ['fail-open', 'fail-closed'],
74
+ },
75
+ },
76
+ },
77
+ scoring: {
78
+ type: 'object',
79
+ additionalProperties: false,
80
+ properties: {
81
+ enabled: { type: 'boolean' },
82
+ defaultScore: { type: 'number', minimum: -10, maximum: 10 },
83
+ minScore: { type: 'number', minimum: -10, maximum: 10 },
84
+ rules: {
85
+ type: 'array',
86
+ items: {
87
+ type: 'object',
88
+ additionalProperties: false,
89
+ required: ['domain', 'score'],
90
+ properties: {
91
+ domain: { type: 'string' },
92
+ score: { type: 'number', minimum: -10, maximum: 10 },
93
+ reason: { type: 'string' },
94
+ },
95
+ },
96
+ },
97
+ feedScores: {
98
+ type: 'object',
99
+ additionalProperties: false,
100
+ properties: {
101
+ urlhaus: {
102
+ type: 'object',
103
+ additionalProperties: false,
104
+ properties: {
105
+ online: { type: 'number', minimum: -10, maximum: 10 },
106
+ offline: { type: 'number', minimum: -10, maximum: 10 },
107
+ },
108
+ },
109
+ spamhaus: {
110
+ type: 'object',
111
+ additionalProperties: false,
112
+ properties: {
113
+ botnet_cc: { type: 'number', minimum: -10, maximum: 10 },
114
+ phishing_domain: { type: 'number', minimum: -10, maximum: 10 },
115
+ spammer_domain: { type: 'number', minimum: -10, maximum: 10 },
116
+ abused_redirector: { type: 'number', minimum: -10, maximum: 10 },
117
+ 'abused_legit_*': { type: 'number', minimum: -10, maximum: 10 },
118
+ },
119
+ },
120
+ },
121
+ },
122
+ },
123
+ },
124
+ logging: {
125
+ type: 'object',
126
+ additionalProperties: false,
127
+ properties: {
128
+ enabled: { type: 'boolean' },
129
+ logBlocked: { type: 'boolean' },
130
+ verboseErrors: { type: 'boolean' },
131
+ },
132
+ },
133
+ },
134
+ } as const;
135
+
136
+ function clampScore(value: number): number {
137
+ if (!Number.isFinite(value)) {
138
+ return 0;
139
+ }
140
+
141
+ return Math.max(-10, Math.min(10, Math.round(value)));
142
+ }
143
+
144
+ function normalizePatternList(values: string[] | undefined): string[] {
145
+ if (!values) {
146
+ return [];
147
+ }
148
+
149
+ return Array.from(
150
+ new Set(values.map((value) => value.trim().toLowerCase()).filter((value) => value.length > 0))
151
+ );
152
+ }
153
+
154
+ export function normalizeConfig(config: UrlGuardConfig | undefined): UrlGuardConfig {
155
+ const source = config ?? DEFAULT_CONFIG;
156
+ const defaultScoring = DEFAULT_CONFIG.scoring;
157
+ const sourceScoring = source.scoring;
158
+ const sourceRules = sourceScoring?.rules ?? defaultScoring?.rules ?? [];
159
+
160
+ return {
161
+ mode: source.mode ?? DEFAULT_CONFIG.mode,
162
+ allowlist: normalizePatternList(source.allowlist ?? DEFAULT_CONFIG.allowlist),
163
+ blocklist: normalizePatternList(source.blocklist ?? DEFAULT_CONFIG.blocklist),
164
+ blockPrivateIps: source.blockPrivateIps ?? DEFAULT_CONFIG.blockPrivateIps,
165
+ threatFeeds: {
166
+ urlhaus: source.threatFeeds?.urlhaus ?? DEFAULT_CONFIG.threatFeeds?.urlhaus,
167
+ mode: source.threatFeeds?.mode ?? DEFAULT_CONFIG.threatFeeds?.mode,
168
+ },
169
+ scoring: {
170
+ enabled: sourceScoring?.enabled ?? defaultScoring?.enabled,
171
+ defaultScore: clampScore(sourceScoring?.defaultScore ?? defaultScoring?.defaultScore ?? 0),
172
+ minScore: clampScore(sourceScoring?.minScore ?? defaultScoring?.minScore ?? -6),
173
+ rules: sourceRules
174
+ .map((rule) => ({
175
+ domain: rule.domain.trim().toLowerCase(),
176
+ score: clampScore(rule.score),
177
+ reason: rule.reason,
178
+ }))
179
+ .filter((rule) => rule.domain.length > 0),
180
+ feedScores: {
181
+ urlhaus: {
182
+ online: clampScore(
183
+ sourceScoring?.feedScores?.urlhaus?.online ??
184
+ defaultScoring?.feedScores?.urlhaus?.online ??
185
+ -10
186
+ ),
187
+ offline: clampScore(
188
+ sourceScoring?.feedScores?.urlhaus?.offline ??
189
+ defaultScoring?.feedScores?.urlhaus?.offline ??
190
+ -7
191
+ ),
192
+ },
193
+ spamhaus: {
194
+ botnet_cc: clampScore(
195
+ sourceScoring?.feedScores?.spamhaus?.botnet_cc ??
196
+ defaultScoring?.feedScores?.spamhaus?.botnet_cc ??
197
+ -10
198
+ ),
199
+ phishing_domain: clampScore(
200
+ sourceScoring?.feedScores?.spamhaus?.phishing_domain ??
201
+ defaultScoring?.feedScores?.spamhaus?.phishing_domain ??
202
+ -10
203
+ ),
204
+ spammer_domain: clampScore(
205
+ sourceScoring?.feedScores?.spamhaus?.spammer_domain ??
206
+ defaultScoring?.feedScores?.spamhaus?.spammer_domain ??
207
+ -8
208
+ ),
209
+ abused_redirector: clampScore(
210
+ sourceScoring?.feedScores?.spamhaus?.abused_redirector ??
211
+ defaultScoring?.feedScores?.spamhaus?.abused_redirector ??
212
+ -4
213
+ ),
214
+ 'abused_legit_*': clampScore(
215
+ sourceScoring?.feedScores?.spamhaus?.['abused_legit_*'] ??
216
+ defaultScoring?.feedScores?.spamhaus?.['abused_legit_*'] ??
217
+ -6
218
+ ),
219
+ },
220
+ },
221
+ },
222
+ logging: {
223
+ enabled: source.logging?.enabled ?? DEFAULT_CONFIG.logging?.enabled,
224
+ logBlocked: source.logging?.logBlocked ?? DEFAULT_CONFIG.logging?.logBlocked,
225
+ verboseErrors: source.logging?.verboseErrors ?? DEFAULT_CONFIG.logging?.verboseErrors,
226
+ },
227
+ };
228
+ }
@@ -0,0 +1,126 @@
1
+ import { normalizeConfig } from '../config';
2
+ import type { UrlGuardConfig } from '../types';
3
+
4
+ const ALLOWED_PROTOCOLS = ['http:', 'https:'];
5
+ const PRIVATE_IP_PATTERNS = [
6
+ /^127\./,
7
+ /^10\./,
8
+ /^172\.(1[6-9]|2\d|3[01])\./,
9
+ /^192\.168\./,
10
+ /^169\.254\./,
11
+ /^0\./,
12
+ /^\[?::1\]?$/i,
13
+ /^\[?fc[0-9a-f]{2}:/i,
14
+ /^\[?fe80:/i,
15
+ /^\[?::ffff:127\./i,
16
+ /^\[?::ffff:10\./i,
17
+ /^\[?::ffff:172\.(1[6-9]|2\d|3[01])\./i,
18
+ /^\[?::ffff:192\.168\./i,
19
+ ];
20
+
21
+ function normalizeHostname(value: string): string {
22
+ return value.trim().toLowerCase().replace(/\.$/, '');
23
+ }
24
+
25
+ export function extractHostname(url: string): string | null {
26
+ try {
27
+ const parsed = new URL(url);
28
+ if (!ALLOWED_PROTOCOLS.includes(parsed.protocol)) {
29
+ return null;
30
+ }
31
+
32
+ return normalizeHostname(parsed.hostname);
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ export function isPrivateIp(hostname: string): boolean {
39
+ return PRIVATE_IP_PATTERNS.some((pattern) => pattern.test(normalizeHostname(hostname)));
40
+ }
41
+
42
+ export function matchesHostnamePattern(hostname: string, pattern: string): boolean {
43
+ const normalizedHost = normalizeHostname(hostname);
44
+ const normalizedPattern = normalizeHostname(pattern);
45
+
46
+ if (normalizedPattern.startsWith('*.')) {
47
+ const suffix = normalizedPattern.slice(2);
48
+ if (!suffix || normalizedHost === suffix) {
49
+ return false;
50
+ }
51
+
52
+ return normalizedHost.endsWith(`.${suffix}`);
53
+ }
54
+
55
+ return normalizedHost === normalizedPattern;
56
+ }
57
+
58
+ function matchesAnyPattern(hostname: string, patterns: string[]): boolean {
59
+ return patterns.some((pattern) => matchesHostnamePattern(hostname, pattern));
60
+ }
61
+
62
+ export function isAllowed(url: string, config: UrlGuardConfig): boolean {
63
+ const hostname = extractHostname(url);
64
+ if (!hostname) {
65
+ return false;
66
+ }
67
+
68
+ const normalizedConfig = normalizeConfig(config);
69
+ if (normalizedConfig.blockPrivateIps && isPrivateIp(hostname)) {
70
+ return false;
71
+ }
72
+
73
+ const allowlist = normalizedConfig.allowlist ?? [];
74
+ const blocklist = normalizedConfig.blocklist ?? [];
75
+
76
+ switch (normalizedConfig.mode) {
77
+ case 'allowlist': {
78
+ return matchesAnyPattern(hostname, allowlist);
79
+ }
80
+ case 'blocklist': {
81
+ return !matchesAnyPattern(hostname, blocklist);
82
+ }
83
+ case 'hybrid': {
84
+ const inAllowlist = matchesAnyPattern(hostname, allowlist);
85
+ const inBlocklist = matchesAnyPattern(hostname, blocklist);
86
+ return inAllowlist && !inBlocklist;
87
+ }
88
+ default:
89
+ return false;
90
+ }
91
+ }
92
+
93
+ export function getBlockReason(url: string, config: UrlGuardConfig): string {
94
+ const hostname = extractHostname(url);
95
+ if (!hostname) {
96
+ return 'invalid URL';
97
+ }
98
+
99
+ const normalizedConfig = normalizeConfig(config);
100
+ if (normalizedConfig.blockPrivateIps && isPrivateIp(hostname)) {
101
+ return 'private or internal IP blocked';
102
+ }
103
+
104
+ const allowlist = normalizedConfig.allowlist ?? [];
105
+ const blocklist = normalizedConfig.blocklist ?? [];
106
+
107
+ if (normalizedConfig.mode === 'allowlist' && !matchesAnyPattern(hostname, allowlist)) {
108
+ return 'not in allowlist';
109
+ }
110
+
111
+ if (normalizedConfig.mode === 'blocklist' && matchesAnyPattern(hostname, blocklist)) {
112
+ return 'in blocklist';
113
+ }
114
+
115
+ if (normalizedConfig.mode === 'hybrid') {
116
+ if (!matchesAnyPattern(hostname, allowlist)) {
117
+ return 'not in allowlist';
118
+ }
119
+
120
+ if (matchesAnyPattern(hostname, blocklist)) {
121
+ return 'in blocklist';
122
+ }
123
+ }
124
+
125
+ return 'blocked by policy';
126
+ }
@@ -0,0 +1,170 @@
1
+ import type { FeedSignal } from '../types';
2
+ import { readFile } from 'node:fs/promises';
3
+
4
+ export interface UrlhausLookupResult {
5
+ listed: boolean;
6
+ unavailable: boolean;
7
+ reason?: string;
8
+ feedSignals?: FeedSignal[];
9
+ }
10
+
11
+ const URLHAUS_LOOKUP_ENDPOINT = 'https://urlhaus-api.abuse.ch/v1/url/';
12
+ const URLHAUS_TIMEOUT_MS = 5000;
13
+ let cachedFixturePath: string | null = null;
14
+ let cachedFixtureSet: Set<string> | null = null;
15
+
16
+ function normalizeUrl(value: string): string {
17
+ return value.trim();
18
+ }
19
+
20
+ async function loadFixtureSet(filePath: string): Promise<Set<string>> {
21
+ if (cachedFixturePath === filePath && cachedFixtureSet) {
22
+ return cachedFixtureSet;
23
+ }
24
+
25
+ const content = await readFile(filePath, 'utf8');
26
+ const lines = content
27
+ .split(/\r?\n/)
28
+ .map((line) => line.trim())
29
+ .filter((line) => line.length > 0);
30
+
31
+ const fixtureUrls = new Set<string>();
32
+ for (const line of lines.slice(1)) {
33
+ const columns = line.split(',');
34
+ const url = columns[2];
35
+ if (url) {
36
+ fixtureUrls.add(normalizeUrl(url));
37
+ }
38
+ }
39
+
40
+ cachedFixturePath = filePath;
41
+ cachedFixtureSet = fixtureUrls;
42
+ return fixtureUrls;
43
+ }
44
+
45
+ function getSpamhausCategory(payload: Record<string, unknown>): string | null {
46
+ const blacklists = payload.blacklists;
47
+ if (!blacklists || typeof blacklists !== 'object') {
48
+ return null;
49
+ }
50
+
51
+ const spamhaus = (blacklists as Record<string, unknown>).spamhaus_dbl;
52
+ if (!spamhaus) {
53
+ return null;
54
+ }
55
+
56
+ if (typeof spamhaus === 'string') {
57
+ const category = spamhaus.trim().toLowerCase();
58
+ return category.length > 0 ? category : null;
59
+ }
60
+
61
+ if (typeof spamhaus === 'object') {
62
+ const candidate = (spamhaus as Record<string, unknown>).category;
63
+ if (typeof candidate === 'string') {
64
+ const category = candidate.trim().toLowerCase();
65
+ return category.length > 0 ? category : null;
66
+ }
67
+ }
68
+
69
+ return null;
70
+ }
71
+
72
+ export async function lookupUrlhaus(url: string): Promise<UrlhausLookupResult> {
73
+ const fixtureFile = process.env.URLHAUS_FIXTURE_FILE;
74
+ if (fixtureFile) {
75
+ try {
76
+ const fixtureSet = await loadFixtureSet(fixtureFile);
77
+ const listed = fixtureSet.has(normalizeUrl(url));
78
+ return {
79
+ listed,
80
+ unavailable: false,
81
+ reason: listed ? 'listed by URLhaus fixture' : undefined,
82
+ ...(listed
83
+ ? {
84
+ feedSignals: [
85
+ {
86
+ feed: 'urlhaus' as const,
87
+ category: 'online',
88
+ reason: 'listed by URLhaus fixture',
89
+ },
90
+ ],
91
+ }
92
+ : {}),
93
+ };
94
+ } catch (error) {
95
+ console.warn('[url-guard] Failed to load URLhaus fixture:', error);
96
+ return {
97
+ listed: false,
98
+ unavailable: true,
99
+ reason: 'urlhaus fixture unavailable',
100
+ };
101
+ }
102
+ }
103
+
104
+ const controller = new AbortController();
105
+ const timeout = setTimeout(() => controller.abort(), URLHAUS_TIMEOUT_MS);
106
+
107
+ try {
108
+ const body = new URLSearchParams({ url });
109
+ const response = await fetch(URLHAUS_LOOKUP_ENDPOINT, {
110
+ method: 'POST',
111
+ headers: {
112
+ 'Content-Type': 'application/x-www-form-urlencoded',
113
+ },
114
+ body,
115
+ signal: controller.signal,
116
+ });
117
+
118
+ if (!response.ok) {
119
+ return {
120
+ listed: false,
121
+ unavailable: true,
122
+ reason: `urlhaus lookup failed (${response.status})`,
123
+ };
124
+ }
125
+
126
+ const payload = (await response.json()) as {
127
+ query_status?: string;
128
+ url_status?: string;
129
+ threat?: string;
130
+ blacklists?: Record<string, unknown>;
131
+ };
132
+
133
+ const urlStatus = payload.url_status?.toLowerCase();
134
+ const listed =
135
+ payload.query_status === 'ok' || urlStatus === 'online' || urlStatus === 'offline';
136
+ const feedSignals: FeedSignal[] = [];
137
+
138
+ if (listed && (urlStatus === 'online' || urlStatus === 'offline')) {
139
+ feedSignals.push({
140
+ feed: 'urlhaus',
141
+ category: urlStatus,
142
+ reason: payload.threat ?? `urlhaus ${urlStatus}`,
143
+ });
144
+ }
145
+
146
+ const spamhausCategory = getSpamhausCategory(payload as Record<string, unknown>);
147
+ if (spamhausCategory) {
148
+ feedSignals.push({
149
+ feed: 'spamhaus',
150
+ category: spamhausCategory,
151
+ reason: `spamhaus_dbl ${spamhausCategory}`,
152
+ });
153
+ }
154
+
155
+ return {
156
+ listed,
157
+ unavailable: false,
158
+ reason: listed ? (payload.threat ?? 'listed by URLhaus') : undefined,
159
+ ...(feedSignals.length > 0 ? { feedSignals } : {}),
160
+ };
161
+ } catch (error) {
162
+ if (error instanceof Error && error.name === 'AbortError') {
163
+ return { listed: false, unavailable: true, reason: 'urlhaus lookup timed out' };
164
+ }
165
+
166
+ return { listed: false, unavailable: true, reason: 'urlhaus lookup unavailable' };
167
+ } finally {
168
+ clearTimeout(timeout);
169
+ }
170
+ }
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ import { configSchema } from './config';
2
+ import { safeWebFetchTool } from './tools/safeFetch';
3
+ import { safeWebSearchTool } from './tools/safeSearch';
4
+ import type { PluginDefinition } from './types';
5
+ import pkg from '../package.json';
6
+
7
+ const plugin: PluginDefinition = {
8
+ name: '@llnvd/openclaw-url-guard',
9
+ version: pkg.version,
10
+ tools: [safeWebFetchTool, safeWebSearchTool],
11
+ configSchema,
12
+ };
13
+
14
+ export default plugin;
@@ -0,0 +1,144 @@
1
+ import { normalizeConfig } from '../config';
2
+ import { extractHostname, matchesHostnamePattern } from '../filters/matcher';
3
+ import type { FeedResults, ScoreResult, ScoringRule, UrlGuardConfig } from '../types';
4
+
5
+ function getRulePriority(rule: ScoringRule): number {
6
+ const isWildcard = rule.domain.startsWith('*.');
7
+ const baseLength = isWildcard ? rule.domain.length - 2 : rule.domain.length;
8
+ return (isWildcard ? 0 : 10_000) + baseLength;
9
+ }
10
+
11
+ function getBestMatchingRule(hostname: string, rules: ScoringRule[]): ScoringRule | null {
12
+ let bestRule: ScoringRule | null = null;
13
+ let bestPriority = -1;
14
+
15
+ for (const rule of rules) {
16
+ if (!matchesHostnamePattern(hostname, rule.domain)) {
17
+ continue;
18
+ }
19
+
20
+ const priority = getRulePriority(rule);
21
+ if (priority > bestPriority) {
22
+ bestPriority = priority;
23
+ bestRule = rule;
24
+ }
25
+ }
26
+
27
+ return bestRule;
28
+ }
29
+
30
+ function mapFeedSignalToScore(
31
+ signal: { feed: 'urlhaus' | 'spamhaus'; category: string },
32
+ config: UrlGuardConfig
33
+ ): number | null {
34
+ const category = signal.category.toLowerCase();
35
+
36
+ if (signal.feed === 'urlhaus') {
37
+ if (category === 'online') {
38
+ return config.scoring?.feedScores?.urlhaus?.online ?? null;
39
+ }
40
+
41
+ if (category === 'offline') {
42
+ return config.scoring?.feedScores?.urlhaus?.offline ?? null;
43
+ }
44
+
45
+ return null;
46
+ }
47
+
48
+ if (category.startsWith('abused_legit_')) {
49
+ return config.scoring?.feedScores?.spamhaus?.['abused_legit_*'] ?? null;
50
+ }
51
+
52
+ switch (category) {
53
+ case 'botnet_cc':
54
+ return config.scoring?.feedScores?.spamhaus?.botnet_cc ?? null;
55
+ case 'phishing_domain':
56
+ return config.scoring?.feedScores?.spamhaus?.phishing_domain ?? null;
57
+ case 'spammer_domain':
58
+ return config.scoring?.feedScores?.spamhaus?.spammer_domain ?? null;
59
+ case 'abused_redirector':
60
+ return config.scoring?.feedScores?.spamhaus?.abused_redirector ?? null;
61
+ default:
62
+ return null;
63
+ }
64
+ }
65
+
66
+ export function calculateScore(
67
+ url: string,
68
+ config: UrlGuardConfig,
69
+ feedResults?: FeedResults
70
+ ): ScoreResult {
71
+ const normalizedConfig = normalizeConfig(config);
72
+ const hostname = extractHostname(url);
73
+
74
+ if (!hostname) {
75
+ return {
76
+ finalScore: -10,
77
+ sources: [{ score: -10, source: 'url', reason: 'invalid URL' }],
78
+ allowed: false,
79
+ };
80
+ }
81
+
82
+ const minScore = normalizedConfig.scoring?.minScore ?? -6;
83
+ const defaultScore = normalizedConfig.scoring?.defaultScore ?? 0;
84
+ const staticRule = getBestMatchingRule(hostname, normalizedConfig.scoring?.rules ?? []);
85
+
86
+ if (staticRule) {
87
+ const finalScore = staticRule.score;
88
+ return {
89
+ finalScore,
90
+ sources: [
91
+ {
92
+ score: staticRule.score,
93
+ source: `static:${staticRule.domain}`,
94
+ reason: staticRule.reason,
95
+ },
96
+ ],
97
+ allowed: finalScore >= minScore,
98
+ };
99
+ }
100
+
101
+ const feedSources: Array<{ score: number; source: string; reason?: string }> = [];
102
+
103
+ for (const signal of feedResults?.signals ?? []) {
104
+ const score = mapFeedSignalToScore(signal, normalizedConfig);
105
+ if (score === null) {
106
+ continue;
107
+ }
108
+
109
+ feedSources.push({
110
+ score,
111
+ source: `${signal.feed}:${signal.category}`,
112
+ reason: signal.reason,
113
+ });
114
+ }
115
+
116
+ if (feedSources.length > 0) {
117
+ const finalScore = Math.min(...feedSources.map((source) => source.score));
118
+ return {
119
+ finalScore,
120
+ sources: feedSources,
121
+ allowed: finalScore >= minScore,
122
+ };
123
+ }
124
+
125
+ if (feedResults?.unavailable && normalizedConfig.threatFeeds?.mode === 'fail-closed') {
126
+ return {
127
+ finalScore: -10,
128
+ sources: [
129
+ {
130
+ score: -10,
131
+ source: 'threat-feed-unavailable',
132
+ reason: feedResults.reason ?? 'threat feed lookup unavailable',
133
+ },
134
+ ],
135
+ allowed: false,
136
+ };
137
+ }
138
+
139
+ return {
140
+ finalScore: defaultScore,
141
+ sources: [{ score: defaultScore, source: 'default-score' }],
142
+ allowed: defaultScore >= minScore,
143
+ };
144
+ }