skill-checker 0.1.0 → 0.1.2

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/index.js CHANGED
@@ -1,3 +1,10 @@
1
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
+ }) : x)(function(x) {
4
+ if (typeof require !== "undefined") return require.apply(this, arguments);
5
+ throw Error('Dynamic require of "' + x + '" is not supported');
6
+ });
7
+
1
8
  // src/parser.ts
2
9
  import { readFileSync, readdirSync, statSync, existsSync } from "fs";
3
10
  import { join, extname, basename, resolve } from "path";
@@ -47,7 +54,8 @@ function parseSkill(dirPath) {
47
54
  const hasSkillMd = existsSync(skillMdPath);
48
55
  const raw = hasSkillMd ? readFileSync(skillMdPath, "utf-8") : "";
49
56
  const { frontmatter, frontmatterValid, body, bodyStartLine } = parseFrontmatter(raw);
50
- const files = enumerateFiles(absDir);
57
+ const warnings = [];
58
+ const files = enumerateFiles(absDir, warnings);
51
59
  return {
52
60
  dirPath: absDir,
53
61
  raw,
@@ -56,7 +64,8 @@ function parseSkill(dirPath) {
56
64
  body,
57
65
  bodyLines: body.split("\n"),
58
66
  bodyStartLine,
59
- files
67
+ files,
68
+ warnings
60
69
  };
61
70
  }
62
71
  function parseSkillContent(content, dirPath = ".") {
@@ -69,7 +78,8 @@ function parseSkillContent(content, dirPath = ".") {
69
78
  body,
70
79
  bodyLines: body.split("\n"),
71
80
  bodyStartLine,
72
- files: []
81
+ files: [],
82
+ warnings: []
73
83
  };
74
84
  }
75
85
  function parseFrontmatter(raw) {
@@ -102,11 +112,20 @@ function parseFrontmatter(raw) {
102
112
  };
103
113
  }
104
114
  }
105
- function enumerateFiles(dirPath, maxDepth = 5) {
115
+ var SKIP_DIRS = /* @__PURE__ */ new Set([".git"]);
116
+ var WARN_SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules"]);
117
+ var MAX_DEPTH = 15;
118
+ var FULL_READ_LIMIT = 5e6;
119
+ var PARTIAL_READ_LIMIT = 512 * 1024;
120
+ function enumerateFiles(dirPath, warnings) {
106
121
  const files = [];
107
122
  if (!existsSync(dirPath)) return files;
108
123
  function walk(currentDir, depth) {
109
- if (depth > maxDepth) return;
124
+ if (depth > MAX_DEPTH) {
125
+ const rel = currentDir.slice(dirPath.length + 1) || currentDir;
126
+ warnings.push(`Depth limit (${MAX_DEPTH}) exceeded at: ${rel}. Contents not scanned.`);
127
+ return;
128
+ }
110
129
  let entries;
111
130
  try {
112
131
  entries = readdirSync(currentDir, { withFileTypes: true });
@@ -116,7 +135,12 @@ function enumerateFiles(dirPath, maxDepth = 5) {
116
135
  for (const entry of entries) {
117
136
  const fullPath = join(currentDir, entry.name);
118
137
  if (entry.isDirectory()) {
119
- if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
138
+ if (SKIP_DIRS.has(entry.name)) continue;
139
+ if (WARN_SKIP_DIRS.has(entry.name)) {
140
+ const rel = fullPath.slice(dirPath.length + 1);
141
+ warnings.push(`Skipped directory: ${rel}. May contain unscanned files.`);
142
+ continue;
143
+ }
120
144
  walk(fullPath, depth + 1);
121
145
  continue;
122
146
  }
@@ -130,10 +154,25 @@ function enumerateFiles(dirPath, maxDepth = 5) {
130
154
  const isBinary = BINARY_EXTENSIONS.has(ext);
131
155
  const relativePath = fullPath.slice(dirPath.length + 1);
132
156
  let content;
133
- if (!isBinary && stats.size < 1e6) {
134
- try {
135
- content = readFileSync(fullPath, "utf-8");
136
- } catch {
157
+ if (!isBinary) {
158
+ if (stats.size <= FULL_READ_LIMIT) {
159
+ try {
160
+ content = readFileSync(fullPath, "utf-8");
161
+ } catch {
162
+ }
163
+ } else if (stats.size <= 5e7) {
164
+ try {
165
+ const fd = __require("fs").openSync(fullPath, "r");
166
+ const buf = Buffer.alloc(PARTIAL_READ_LIMIT);
167
+ const bytesRead = __require("fs").readSync(fd, buf, 0, PARTIAL_READ_LIMIT, 0);
168
+ __require("fs").closeSync(fd);
169
+ content = buf.slice(0, bytesRead).toString("utf-8");
170
+ warnings.push(`Large file partially scanned (first ${PARTIAL_READ_LIMIT} bytes): ${relativePath} (${stats.size} bytes total)`);
171
+ } catch {
172
+ warnings.push(`Large file could not be read: ${relativePath} (${stats.size} bytes)`);
173
+ }
174
+ } else {
175
+ warnings.push(`File too large to scan: ${relativePath} (${stats.size} bytes). Content not checked.`);
137
176
  }
138
177
  }
139
178
  files.push({
@@ -257,6 +296,15 @@ var structuralChecks = {
257
296
  });
258
297
  }
259
298
  }
299
+ for (const warning of skill.warnings) {
300
+ results.push({
301
+ id: "STRUCT-008",
302
+ category: "STRUCT",
303
+ severity: "MEDIUM",
304
+ title: "Scan coverage warning",
305
+ message: warning
306
+ });
307
+ }
260
308
  return results;
261
309
  }
262
310
  };
@@ -307,6 +355,14 @@ function isInDocumentationContext(lines, lineIndex) {
307
355
  }
308
356
  return false;
309
357
  }
358
+ function isLicenseFile(filePath) {
359
+ const name = filePath.split("/").pop()?.toUpperCase() ?? "";
360
+ const base = name.replace(/\.[^.]+$/, "");
361
+ return /^(LICENSE|LICENCE|COPYING|NOTICE|AUTHORS|PATENTS)$/.test(base);
362
+ }
363
+ function isLocalhostURL(url) {
364
+ return /^https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])/i.test(url);
365
+ }
310
366
  function parseURLPath(url) {
311
367
  try {
312
368
  const u = new URL(url);
@@ -901,6 +957,66 @@ function dedup(results) {
901
957
  });
902
958
  }
903
959
 
960
+ // src/types.ts
961
+ var SEVERITY_SCORES = {
962
+ CRITICAL: 25,
963
+ HIGH: 10,
964
+ MEDIUM: 3,
965
+ LOW: 1
966
+ };
967
+ function computeGrade(score) {
968
+ if (score >= 90) return "A";
969
+ if (score >= 75) return "B";
970
+ if (score >= 60) return "C";
971
+ if (score >= 40) return "D";
972
+ return "F";
973
+ }
974
+ var REDUCE_MAP = {
975
+ CRITICAL: "HIGH",
976
+ HIGH: "MEDIUM",
977
+ MEDIUM: "LOW",
978
+ LOW: "LOW"
979
+ };
980
+ function reduceSeverity(original, reason) {
981
+ let reduced = REDUCE_MAP[original];
982
+ if (original === "CRITICAL" && reduced === "LOW") {
983
+ reduced = "MEDIUM";
984
+ }
985
+ return {
986
+ severity: reduced,
987
+ reducedFrom: original,
988
+ annotation: `[reduced: ${reason}]`
989
+ };
990
+ }
991
+ var DEFAULT_CONFIG = {
992
+ policy: "balanced",
993
+ overrides: {},
994
+ ignore: []
995
+ };
996
+ function getHookAction(policy, severity) {
997
+ const matrix = {
998
+ strict: {
999
+ CRITICAL: "deny",
1000
+ HIGH: "deny",
1001
+ MEDIUM: "ask",
1002
+ LOW: "report"
1003
+ },
1004
+ balanced: {
1005
+ CRITICAL: "deny",
1006
+ HIGH: "ask",
1007
+ MEDIUM: "report",
1008
+ LOW: "report"
1009
+ },
1010
+ permissive: {
1011
+ CRITICAL: "ask",
1012
+ HIGH: "report",
1013
+ MEDIUM: "report",
1014
+ LOW: "report"
1015
+ }
1016
+ };
1017
+ return matrix[policy][severity];
1018
+ }
1019
+
904
1020
  // src/checks/code-safety.ts
905
1021
  var EVAL_PATTERNS = [
906
1022
  /\beval\s*\(/,
@@ -984,6 +1100,7 @@ var codeSafetyChecks = {
984
1100
  const line = lines[i];
985
1101
  const lineNum = i + 1;
986
1102
  const loc = `${source}:${lineNum}`;
1103
+ const cbCtx = { lines, index: i };
987
1104
  checkPatterns(results, line, EVAL_PATTERNS, {
988
1105
  id: "CODE-001",
989
1106
  severity: "CRITICAL",
@@ -1005,14 +1122,16 @@ var codeSafetyChecks = {
1005
1122
  severity: "CRITICAL",
1006
1123
  title: "Destructive file operation",
1007
1124
  loc,
1008
- lineNum
1125
+ lineNum,
1126
+ codeBlockCtx: cbCtx
1009
1127
  });
1010
1128
  checkPatterns(results, line, NETWORK_PATTERNS, {
1011
1129
  id: "CODE-004",
1012
1130
  severity: "HIGH",
1013
1131
  title: "Hardcoded external URL/network request",
1014
1132
  loc,
1015
- lineNum
1133
+ lineNum,
1134
+ codeBlockCtx: cbCtx
1016
1135
  });
1017
1136
  checkPatterns(results, line, FILE_WRITE_PATTERNS, {
1018
1137
  id: "CODE-005",
@@ -1026,7 +1145,8 @@ var codeSafetyChecks = {
1026
1145
  severity: "MEDIUM",
1027
1146
  title: "Environment variable access",
1028
1147
  loc,
1029
- lineNum
1148
+ lineNum,
1149
+ codeBlockCtx: cbCtx
1030
1150
  });
1031
1151
  checkPatterns(results, line, DYNAMIC_CODE_PATTERNS, {
1032
1152
  id: "CODE-010",
@@ -1036,8 +1156,7 @@ var codeSafetyChecks = {
1036
1156
  lineNum
1037
1157
  });
1038
1158
  {
1039
- const srcLines = text.split("\n");
1040
- const isDoc = isInDocumentationContext(srcLines, i);
1159
+ const isDoc = isInDocumentationContext(lines, i);
1041
1160
  if (!isDoc) {
1042
1161
  checkPatterns(results, line, PERMISSION_PATTERNS, {
1043
1162
  id: "CODE-012",
@@ -1058,14 +1177,24 @@ var codeSafetyChecks = {
1058
1177
  function checkPatterns(results, line, patterns, opts) {
1059
1178
  for (const pattern of patterns) {
1060
1179
  if (pattern.test(line)) {
1180
+ let severity = opts.severity;
1181
+ let reducedFrom;
1182
+ let msgSuffix = "";
1183
+ if (opts.codeBlockCtx && isInCodeBlock(opts.codeBlockCtx.lines, opts.codeBlockCtx.index)) {
1184
+ const r = reduceSeverity(severity, "in code block");
1185
+ severity = r.severity;
1186
+ reducedFrom = r.reducedFrom;
1187
+ msgSuffix = ` ${r.annotation}`;
1188
+ }
1061
1189
  results.push({
1062
1190
  id: opts.id,
1063
1191
  category: "CODE",
1064
- severity: opts.severity,
1192
+ severity,
1065
1193
  title: opts.title,
1066
- message: `At ${opts.loc}: ${line.trim().slice(0, 120)}`,
1194
+ message: `At ${opts.loc}: ${line.trim().slice(0, 120)}${msgSuffix}`,
1067
1195
  line: opts.lineNum,
1068
- snippet: line.trim().slice(0, 120)
1196
+ snippet: line.trim().slice(0, 120),
1197
+ reducedFrom
1069
1198
  });
1070
1199
  return;
1071
1200
  }
@@ -1148,22 +1277,147 @@ function scanObfuscation(results, text, source) {
1148
1277
  }
1149
1278
  }
1150
1279
 
1280
+ // src/ioc/index.ts
1281
+ import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
1282
+ import { join as join2 } from "path";
1283
+ import { homedir } from "os";
1284
+
1285
+ // src/ioc/indicators.ts
1286
+ var DEFAULT_IOC = {
1287
+ version: "2026.03.06",
1288
+ updated: "2026-03-06",
1289
+ c2_ips: [
1290
+ "91.92.242.30",
1291
+ "91.92.242.39",
1292
+ "185.220.101.1",
1293
+ "185.220.101.2",
1294
+ "45.155.205.233"
1295
+ ],
1296
+ malicious_hashes: {
1297
+ // NOTE: Never add the SHA-256 of an empty file (e3b0c44298fc...b855)
1298
+ // as it causes false positives on any empty file.
1299
+ "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2": "clawhavoc-exfiltrator"
1300
+ },
1301
+ malicious_domains: [
1302
+ "webhook.site",
1303
+ "requestbin.com",
1304
+ "pipedream.com",
1305
+ "pipedream.net",
1306
+ "hookbin.com",
1307
+ "beeceptor.com",
1308
+ "ngrok.io",
1309
+ "ngrok-free.app",
1310
+ "serveo.net",
1311
+ "localtunnel.me",
1312
+ "bore.pub",
1313
+ "interact.sh",
1314
+ "oast.fun",
1315
+ "oastify.com",
1316
+ "dnslog.cn",
1317
+ "ceye.io",
1318
+ "burpcollaborator.net",
1319
+ "pastebin.com",
1320
+ "paste.ee",
1321
+ "hastebin.com",
1322
+ "ghostbin.com",
1323
+ "evil.com",
1324
+ "malware.com",
1325
+ "exploit.in"
1326
+ ],
1327
+ typosquat: {
1328
+ known_patterns: [
1329
+ "clawhub1",
1330
+ "cllawhub",
1331
+ "clawhab",
1332
+ "moltbot",
1333
+ "claw-hub",
1334
+ "clawhub-pro"
1335
+ ],
1336
+ protected_names: [
1337
+ "clawhub",
1338
+ "secureclaw",
1339
+ "openclaw",
1340
+ "clawbot",
1341
+ "claude",
1342
+ "anthropic",
1343
+ "skill-checker"
1344
+ ]
1345
+ },
1346
+ malicious_publishers: [
1347
+ "clawhavoc",
1348
+ "phantom-tracker",
1349
+ "solana-wallet-drainer"
1350
+ ]
1351
+ };
1352
+
1353
+ // src/ioc/index.ts
1354
+ var cachedIOC = null;
1355
+ function loadIOC() {
1356
+ if (cachedIOC) return cachedIOC;
1357
+ const ioc = structuredClone(DEFAULT_IOC);
1358
+ const overridePath = join2(
1359
+ homedir(),
1360
+ ".config",
1361
+ "skill-checker",
1362
+ "ioc-override.json"
1363
+ );
1364
+ if (existsSync2(overridePath)) {
1365
+ try {
1366
+ const raw = readFileSync2(overridePath, "utf-8");
1367
+ const ext = JSON.parse(raw);
1368
+ mergeIOC(ioc, ext);
1369
+ } catch {
1370
+ }
1371
+ }
1372
+ cachedIOC = ioc;
1373
+ return ioc;
1374
+ }
1375
+ function resetIOCCache() {
1376
+ cachedIOC = null;
1377
+ }
1378
+ function mergeIOC(base, ext) {
1379
+ if (ext.c2_ips) {
1380
+ base.c2_ips = dedupe([...base.c2_ips, ...ext.c2_ips]);
1381
+ }
1382
+ if (ext.malicious_hashes) {
1383
+ Object.assign(base.malicious_hashes, ext.malicious_hashes);
1384
+ }
1385
+ if (ext.malicious_domains) {
1386
+ base.malicious_domains = dedupe([
1387
+ ...base.malicious_domains,
1388
+ ...ext.malicious_domains
1389
+ ]);
1390
+ }
1391
+ if (ext.typosquat) {
1392
+ if (ext.typosquat.known_patterns) {
1393
+ base.typosquat.known_patterns = dedupe([
1394
+ ...base.typosquat.known_patterns,
1395
+ ...ext.typosquat.known_patterns
1396
+ ]);
1397
+ }
1398
+ if (ext.typosquat.protected_names) {
1399
+ base.typosquat.protected_names = dedupe([
1400
+ ...base.typosquat.protected_names,
1401
+ ...ext.typosquat.protected_names
1402
+ ]);
1403
+ }
1404
+ }
1405
+ if (ext.malicious_publishers) {
1406
+ base.malicious_publishers = dedupe([
1407
+ ...base.malicious_publishers,
1408
+ ...ext.malicious_publishers
1409
+ ]);
1410
+ }
1411
+ if (ext.version) base.version = ext.version;
1412
+ if (ext.updated) base.updated = ext.updated;
1413
+ }
1414
+ function dedupe(arr) {
1415
+ return [...new Set(arr)];
1416
+ }
1417
+
1151
1418
  // src/checks/supply-chain.ts
1152
- var SUSPICIOUS_DOMAINS = [
1153
- "evil.com",
1154
- "malware.com",
1155
- "exploit.in",
1156
- "darkweb.onion",
1157
- "pastebin.com",
1158
- // often used for payload hosting
1159
- "ngrok.io",
1160
- // tunneling service
1161
- "requestbin.com",
1162
- "webhook.site",
1163
- "pipedream.net",
1164
- "burpcollaborator.net",
1165
- "interact.sh",
1166
- "oastify.com"
1419
+ var FALLBACK_SUSPICIOUS_DOMAINS = [
1420
+ "darkweb.onion"
1167
1421
  ];
1168
1422
  var MCP_SERVER_PATTERN = /\bmcp[-_]?server\b/i;
1169
1423
  var NPX_Y_PATTERN = /\bnpx\s+-y\s+/;
@@ -1172,23 +1426,47 @@ var PIP_INSTALL_PATTERN = /\bpip3?\s+install\b/;
1172
1426
  var GIT_CLONE_PATTERN = /\bgit\s+clone\b/;
1173
1427
  var URL_PATTERN = /https?:\/\/[^\s"'`<>)\]]+/g;
1174
1428
  var IP_URL_PATTERN = /https?:\/\/(?:\d{1,3}\.){3}\d{1,3}/;
1429
+ function extractHostname(url) {
1430
+ const afterProto = url.replace(/^https?:\/\//, "");
1431
+ const hostPort = afterProto.split("/")[0].split("?")[0].split("#")[0];
1432
+ const host = hostPort.split(":")[0];
1433
+ return host.toLowerCase();
1434
+ }
1435
+ function hostnameMatchesDomain(hostname, domain) {
1436
+ const d = domain.toLowerCase();
1437
+ return hostname === d || hostname.endsWith("." + d);
1438
+ }
1175
1439
  var supplyChainChecks = {
1176
1440
  name: "Supply Chain",
1177
1441
  category: "SUPPLY",
1178
1442
  run(skill) {
1179
1443
  const results = [];
1180
1444
  const allText = getAllText(skill);
1445
+ const ioc = loadIOC();
1446
+ const suspiciousDomains = ioc.malicious_domains.length > 0 ? ioc.malicious_domains : FALLBACK_SUSPICIOUS_DOMAINS;
1181
1447
  for (let i = 0; i < allText.length; i++) {
1182
1448
  const { line, lineNum, source } = allText[i];
1183
1449
  if (MCP_SERVER_PATTERN.test(line)) {
1450
+ let severity = "HIGH";
1451
+ let reducedFrom;
1452
+ let msgSuffix = "";
1453
+ const srcLines = getLinesForSource(skill, source);
1454
+ const localIdx = getLocalIndex(source, lineNum, skill.bodyStartLine);
1455
+ if (localIdx >= 0 && isInCodeBlock(srcLines, localIdx)) {
1456
+ const r = reduceSeverity(severity, "in code block");
1457
+ severity = r.severity;
1458
+ reducedFrom = r.reducedFrom;
1459
+ msgSuffix = ` ${r.annotation}`;
1460
+ }
1184
1461
  results.push({
1185
1462
  id: "SUPPLY-001",
1186
1463
  category: "SUPPLY",
1187
- severity: "HIGH",
1464
+ severity,
1188
1465
  title: "MCP server reference",
1189
- message: `${source}:${lineNum}: References an MCP server. Verify it is from a trusted source.`,
1466
+ message: `${source}:${lineNum}: References an MCP server. Verify it is from a trusted source.${msgSuffix}`,
1190
1467
  line: lineNum,
1191
- snippet: line.trim().slice(0, 120)
1468
+ snippet: line.trim().slice(0, 120),
1469
+ reducedFrom
1192
1470
  });
1193
1471
  }
1194
1472
  if (NPX_Y_PATTERN.test(line)) {
@@ -1210,14 +1488,26 @@ var supplyChainChecks = {
1210
1488
  globalIdx
1211
1489
  );
1212
1490
  if (!isDoc) {
1491
+ let severity = "HIGH";
1492
+ let reducedFrom;
1493
+ let msgSuffix = "";
1494
+ const srcLines = getLinesForSource(skill, source);
1495
+ const localIdx = getLocalIndex(source, lineNum, skill.bodyStartLine);
1496
+ if (localIdx >= 0 && isInCodeBlock(srcLines, localIdx)) {
1497
+ const r = reduceSeverity(severity, "in code block");
1498
+ severity = r.severity;
1499
+ reducedFrom = r.reducedFrom;
1500
+ msgSuffix = ` ${r.annotation}`;
1501
+ }
1213
1502
  results.push({
1214
1503
  id: "SUPPLY-003",
1215
1504
  category: "SUPPLY",
1216
- severity: "HIGH",
1505
+ severity,
1217
1506
  title: "Package installation command",
1218
- message: `${source}:${lineNum}: Installs packages. Verify package names are legitimate.`,
1507
+ message: `${source}:${lineNum}: Installs packages. Verify package names are legitimate.${msgSuffix}`,
1219
1508
  line: lineNum,
1220
- snippet: line.trim().slice(0, 120)
1509
+ snippet: line.trim().slice(0, 120),
1510
+ reducedFrom
1221
1511
  });
1222
1512
  }
1223
1513
  }
@@ -1235,16 +1525,30 @@ var supplyChainChecks = {
1235
1525
  const urls = line.match(URL_PATTERN) || [];
1236
1526
  for (const url of urls) {
1237
1527
  if (url.startsWith("http://")) {
1528
+ if (isLicenseFile(source)) continue;
1529
+ if (isLocalhostURL(url)) continue;
1238
1530
  if (!isNamespaceOrSchemaURI(url, line)) {
1239
1531
  const isNetworkCtx = isInNetworkRequestContext(line);
1532
+ let severity = isNetworkCtx ? "HIGH" : "MEDIUM";
1533
+ let reducedFrom;
1534
+ let msgSuffix = "";
1535
+ const srcLines = getLinesForSource(skill, source);
1536
+ const localIdx = getLocalIndex(source, lineNum, skill.bodyStartLine);
1537
+ if (localIdx >= 0 && isInCodeBlock(srcLines, localIdx)) {
1538
+ const r = reduceSeverity(severity, "in code block");
1539
+ severity = r.severity;
1540
+ reducedFrom = r.reducedFrom;
1541
+ msgSuffix = ` ${r.annotation}`;
1542
+ }
1240
1543
  results.push({
1241
1544
  id: "SUPPLY-004",
1242
1545
  category: "SUPPLY",
1243
- severity: isNetworkCtx ? "HIGH" : "MEDIUM",
1546
+ severity,
1244
1547
  title: "Non-HTTPS URL",
1245
- message: `${source}:${lineNum}: Uses insecure HTTP: ${url}`,
1548
+ message: `${source}:${lineNum}: Uses insecure HTTP: ${url}${msgSuffix}`,
1246
1549
  line: lineNum,
1247
- snippet: url
1550
+ snippet: url,
1551
+ reducedFrom
1248
1552
  });
1249
1553
  }
1250
1554
  }
@@ -1261,8 +1565,9 @@ var supplyChainChecks = {
1261
1565
  });
1262
1566
  }
1263
1567
  }
1264
- for (const domain of SUSPICIOUS_DOMAINS) {
1265
- if (url.includes(domain)) {
1568
+ const hostname = extractHostname(url);
1569
+ for (const domain of suspiciousDomains) {
1570
+ if (hostnameMatchesDomain(hostname, domain)) {
1266
1571
  results.push({
1267
1572
  id: "SUPPLY-007",
1268
1573
  category: "SUPPLY",
@@ -1280,6 +1585,15 @@ var supplyChainChecks = {
1280
1585
  return results;
1281
1586
  }
1282
1587
  };
1588
+ function getLinesForSource(skill, source) {
1589
+ if (source === "SKILL.md") return skill.bodyLines;
1590
+ const file = skill.files.find((f) => f.path === source);
1591
+ return file?.content?.split("\n") ?? [];
1592
+ }
1593
+ function getLocalIndex(source, lineNum, bodyStartLine) {
1594
+ if (source === "SKILL.md") return lineNum - bodyStartLine;
1595
+ return lineNum - 1;
1596
+ }
1283
1597
  function getAllText(skill) {
1284
1598
  const result = [];
1285
1599
  for (let i = 0; i < skill.bodyLines.length; i++) {
@@ -1459,146 +1773,9 @@ var resourceChecks = {
1459
1773
  }
1460
1774
  };
1461
1775
 
1462
- // src/ioc/index.ts
1463
- import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
1464
- import { join as join2 } from "path";
1465
- import { homedir } from "os";
1466
-
1467
- // src/ioc/indicators.ts
1468
- var DEFAULT_IOC = {
1469
- version: "2026.03.06",
1470
- updated: "2026-03-06",
1471
- c2_ips: [
1472
- "91.92.242.30",
1473
- "91.92.242.39",
1474
- "185.220.101.1",
1475
- "185.220.101.2",
1476
- "45.155.205.233"
1477
- ],
1478
- malicious_hashes: {
1479
- "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855": "clawhavoc-empty-payload",
1480
- "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2": "clawhavoc-exfiltrator"
1481
- },
1482
- malicious_domains: [
1483
- "webhook.site",
1484
- "requestbin.com",
1485
- "pipedream.com",
1486
- "pipedream.net",
1487
- "hookbin.com",
1488
- "beeceptor.com",
1489
- "ngrok.io",
1490
- "ngrok-free.app",
1491
- "serveo.net",
1492
- "localtunnel.me",
1493
- "bore.pub",
1494
- "interact.sh",
1495
- "oast.fun",
1496
- "oastify.com",
1497
- "dnslog.cn",
1498
- "ceye.io",
1499
- "burpcollaborator.net",
1500
- "pastebin.com",
1501
- "paste.ee",
1502
- "hastebin.com",
1503
- "ghostbin.com",
1504
- "evil.com",
1505
- "malware.com",
1506
- "exploit.in"
1507
- ],
1508
- typosquat: {
1509
- known_patterns: [
1510
- "clawhub1",
1511
- "cllawhub",
1512
- "clawhab",
1513
- "moltbot",
1514
- "claw-hub",
1515
- "clawhub-pro"
1516
- ],
1517
- protected_names: [
1518
- "clawhub",
1519
- "secureclaw",
1520
- "openclaw",
1521
- "clawbot",
1522
- "claude",
1523
- "anthropic",
1524
- "skill-checker"
1525
- ]
1526
- },
1527
- malicious_publishers: [
1528
- "clawhavoc",
1529
- "phantom-tracker",
1530
- "solana-wallet-drainer"
1531
- ]
1532
- };
1533
-
1534
- // src/ioc/index.ts
1535
- var cachedIOC = null;
1536
- function loadIOC() {
1537
- if (cachedIOC) return cachedIOC;
1538
- const ioc = structuredClone(DEFAULT_IOC);
1539
- const overridePath = join2(
1540
- homedir(),
1541
- ".config",
1542
- "skill-checker",
1543
- "ioc-override.json"
1544
- );
1545
- if (existsSync2(overridePath)) {
1546
- try {
1547
- const raw = readFileSync2(overridePath, "utf-8");
1548
- const ext = JSON.parse(raw);
1549
- mergeIOC(ioc, ext);
1550
- } catch {
1551
- }
1552
- }
1553
- cachedIOC = ioc;
1554
- return ioc;
1555
- }
1556
- function resetIOCCache() {
1557
- cachedIOC = null;
1558
- }
1559
- function mergeIOC(base, ext) {
1560
- if (ext.c2_ips) {
1561
- base.c2_ips = dedupe([...base.c2_ips, ...ext.c2_ips]);
1562
- }
1563
- if (ext.malicious_hashes) {
1564
- Object.assign(base.malicious_hashes, ext.malicious_hashes);
1565
- }
1566
- if (ext.malicious_domains) {
1567
- base.malicious_domains = dedupe([
1568
- ...base.malicious_domains,
1569
- ...ext.malicious_domains
1570
- ]);
1571
- }
1572
- if (ext.typosquat) {
1573
- if (ext.typosquat.known_patterns) {
1574
- base.typosquat.known_patterns = dedupe([
1575
- ...base.typosquat.known_patterns,
1576
- ...ext.typosquat.known_patterns
1577
- ]);
1578
- }
1579
- if (ext.typosquat.protected_names) {
1580
- base.typosquat.protected_names = dedupe([
1581
- ...base.typosquat.protected_names,
1582
- ...ext.typosquat.protected_names
1583
- ]);
1584
- }
1585
- }
1586
- if (ext.malicious_publishers) {
1587
- base.malicious_publishers = dedupe([
1588
- ...base.malicious_publishers,
1589
- ...ext.malicious_publishers
1590
- ]);
1591
- }
1592
- if (ext.version) base.version = ext.version;
1593
- if (ext.updated) base.updated = ext.updated;
1594
- }
1595
- function dedupe(arr) {
1596
- return [...new Set(arr)];
1597
- }
1598
-
1599
1776
  // src/ioc/matcher.ts
1600
1777
  import { createHash } from "crypto";
1601
- import { readFileSync as readFileSync3 } from "fs";
1778
+ import { openSync, readSync, closeSync, statSync as statSync2 } from "fs";
1602
1779
  import { join as join3 } from "path";
1603
1780
 
1604
1781
  // src/utils/levenshtein.ts
@@ -1631,6 +1808,7 @@ function levenshtein(a, b) {
1631
1808
  }
1632
1809
 
1633
1810
  // src/ioc/matcher.ts
1811
+ var EMPTY_FILE_HASH = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
1634
1812
  var IPV4_PATTERN = /\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/g;
1635
1813
  function isPrivateIP(ip) {
1636
1814
  const parts = ip.split(".").map(Number);
@@ -1643,6 +1821,24 @@ function isPrivateIP(ip) {
1643
1821
  if (parts[0] === 169 && parts[1] === 254) return true;
1644
1822
  return false;
1645
1823
  }
1824
+ var HASH_CHUNK_SIZE = 64 * 1024;
1825
+ function computeFileHash(filePath) {
1826
+ const hash = createHash("sha256");
1827
+ const fd = openSync(filePath, "r");
1828
+ try {
1829
+ const buf = Buffer.alloc(HASH_CHUNK_SIZE);
1830
+ let bytesRead;
1831
+ do {
1832
+ bytesRead = readSync(fd, buf, 0, HASH_CHUNK_SIZE, null);
1833
+ if (bytesRead > 0) {
1834
+ hash.update(buf.subarray(0, bytesRead));
1835
+ }
1836
+ } while (bytesRead > 0);
1837
+ } finally {
1838
+ closeSync(fd);
1839
+ }
1840
+ return hash.digest("hex");
1841
+ }
1646
1842
  function matchMaliciousHashes(skill, ioc) {
1647
1843
  const matches = [];
1648
1844
  const hashKeys = Object.keys(ioc.malicious_hashes);
@@ -1650,8 +1846,10 @@ function matchMaliciousHashes(skill, ioc) {
1650
1846
  for (const file of skill.files) {
1651
1847
  const filePath = join3(skill.dirPath, file.path);
1652
1848
  try {
1653
- const content = readFileSync3(filePath);
1654
- const hash = createHash("sha256").update(content).digest("hex");
1849
+ const stat = statSync2(filePath);
1850
+ if (stat.size === 0) continue;
1851
+ const hash = computeFileHash(filePath);
1852
+ if (hash === EMPTY_FILE_HASH) continue;
1655
1853
  if (ioc.malicious_hashes[hash]) {
1656
1854
  matches.push({
1657
1855
  file: file.path,
@@ -1801,49 +1999,6 @@ function runAllChecks(skill) {
1801
1999
  return results;
1802
2000
  }
1803
2001
 
1804
- // src/types.ts
1805
- var SEVERITY_SCORES = {
1806
- CRITICAL: 25,
1807
- HIGH: 10,
1808
- MEDIUM: 3,
1809
- LOW: 1
1810
- };
1811
- function computeGrade(score) {
1812
- if (score >= 90) return "A";
1813
- if (score >= 75) return "B";
1814
- if (score >= 60) return "C";
1815
- if (score >= 40) return "D";
1816
- return "F";
1817
- }
1818
- var DEFAULT_CONFIG = {
1819
- policy: "balanced",
1820
- overrides: {},
1821
- ignore: []
1822
- };
1823
- function getHookAction(policy, severity) {
1824
- const matrix = {
1825
- strict: {
1826
- CRITICAL: "deny",
1827
- HIGH: "deny",
1828
- MEDIUM: "ask",
1829
- LOW: "report"
1830
- },
1831
- balanced: {
1832
- CRITICAL: "deny",
1833
- HIGH: "ask",
1834
- MEDIUM: "report",
1835
- LOW: "report"
1836
- },
1837
- permissive: {
1838
- CRITICAL: "ask",
1839
- HIGH: "report",
1840
- MEDIUM: "report",
1841
- LOW: "report"
1842
- }
1843
- };
1844
- return matrix[policy][severity];
1845
- }
1846
-
1847
2002
  // src/scanner.ts
1848
2003
  function scanSkillDirectory(dirPath, config = DEFAULT_CONFIG) {
1849
2004
  const skill = parseSkill(dirPath);
@@ -1862,6 +2017,7 @@ function buildReport(skill, config) {
1862
2017
  return r;
1863
2018
  });
1864
2019
  results = results.filter((r) => !config.ignore.includes(r.id));
2020
+ results = deduplicateResults(results);
1865
2021
  const score = calculateScore(results);
1866
2022
  const grade = computeGrade(score);
1867
2023
  const summary = {
@@ -1881,6 +2037,40 @@ function buildReport(skill, config) {
1881
2037
  summary
1882
2038
  };
1883
2039
  }
2040
+ function deduplicateResults(results) {
2041
+ const groups = /* @__PURE__ */ new Map();
2042
+ const severityOrder = {
2043
+ CRITICAL: 4,
2044
+ HIGH: 3,
2045
+ MEDIUM: 2,
2046
+ LOW: 1
2047
+ };
2048
+ for (const r of results) {
2049
+ const sourceFile = extractSourceFile(r.message);
2050
+ const key = `${r.id}::${sourceFile}`;
2051
+ const group = groups.get(key);
2052
+ if (group) {
2053
+ group.push(r);
2054
+ } else {
2055
+ groups.set(key, [r]);
2056
+ }
2057
+ }
2058
+ const deduped = [];
2059
+ for (const group of groups.values()) {
2060
+ group.sort((a, b) => severityOrder[b.severity] - severityOrder[a.severity]);
2061
+ const best = { ...group[0] };
2062
+ if (group.length > 1) {
2063
+ best.occurrences = group.length;
2064
+ best.message += ` (${group.length} occurrences in this file)`;
2065
+ }
2066
+ deduped.push(best);
2067
+ }
2068
+ return deduped;
2069
+ }
2070
+ function extractSourceFile(message) {
2071
+ const m = message.match(/^(?:At\s+)?([^:]+):\d+:/);
2072
+ return m?.[1] ?? "unknown";
2073
+ }
1884
2074
  function calculateScore(results) {
1885
2075
  let score = 100;
1886
2076
  for (const r of results) {
@@ -2067,7 +2257,7 @@ function buildReportSummary(report) {
2067
2257
  }
2068
2258
 
2069
2259
  // src/config.ts
2070
- import { readFileSync as readFileSync4, existsSync as existsSync3 } from "fs";
2260
+ import { readFileSync as readFileSync3, existsSync as existsSync3 } from "fs";
2071
2261
  import { join as join4, resolve as resolve2 } from "path";
2072
2262
  import { parse as parseYaml2 } from "yaml";
2073
2263
  var CONFIG_FILENAMES = [
@@ -2109,7 +2299,7 @@ function loadConfig(startDir, configPath) {
2109
2299
  }
2110
2300
  function parseConfigFile(path) {
2111
2301
  try {
2112
- const raw = readFileSync4(path, "utf-8");
2302
+ const raw = readFileSync3(path, "utf-8");
2113
2303
  const parsed = parseYaml2(raw);
2114
2304
  if (!parsed || typeof parsed !== "object") {
2115
2305
  return { ...DEFAULT_CONFIG };
@@ -2159,6 +2349,7 @@ export {
2159
2349
  loadIOC,
2160
2350
  parseSkill,
2161
2351
  parseSkillContent,
2352
+ reduceSeverity,
2162
2353
  resetIOCCache,
2163
2354
  runAllChecks,
2164
2355
  scanSkillContent,