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/cli.js CHANGED
@@ -1,4 +1,12 @@
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/cli.ts
9
+ import { createRequire } from "module";
2
10
  import { Command } from "commander";
3
11
 
4
12
  // src/parser.ts
@@ -50,7 +58,8 @@ function parseSkill(dirPath) {
50
58
  const hasSkillMd = existsSync(skillMdPath);
51
59
  const raw = hasSkillMd ? readFileSync(skillMdPath, "utf-8") : "";
52
60
  const { frontmatter, frontmatterValid, body, bodyStartLine } = parseFrontmatter(raw);
53
- const files = enumerateFiles(absDir);
61
+ const warnings = [];
62
+ const files = enumerateFiles(absDir, warnings);
54
63
  return {
55
64
  dirPath: absDir,
56
65
  raw,
@@ -59,7 +68,8 @@ function parseSkill(dirPath) {
59
68
  body,
60
69
  bodyLines: body.split("\n"),
61
70
  bodyStartLine,
62
- files
71
+ files,
72
+ warnings
63
73
  };
64
74
  }
65
75
  function parseFrontmatter(raw) {
@@ -92,11 +102,20 @@ function parseFrontmatter(raw) {
92
102
  };
93
103
  }
94
104
  }
95
- function enumerateFiles(dirPath, maxDepth = 5) {
105
+ var SKIP_DIRS = /* @__PURE__ */ new Set([".git"]);
106
+ var WARN_SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules"]);
107
+ var MAX_DEPTH = 15;
108
+ var FULL_READ_LIMIT = 5e6;
109
+ var PARTIAL_READ_LIMIT = 512 * 1024;
110
+ function enumerateFiles(dirPath, warnings) {
96
111
  const files = [];
97
112
  if (!existsSync(dirPath)) return files;
98
113
  function walk(currentDir, depth) {
99
- if (depth > maxDepth) return;
114
+ if (depth > MAX_DEPTH) {
115
+ const rel = currentDir.slice(dirPath.length + 1) || currentDir;
116
+ warnings.push(`Depth limit (${MAX_DEPTH}) exceeded at: ${rel}. Contents not scanned.`);
117
+ return;
118
+ }
100
119
  let entries;
101
120
  try {
102
121
  entries = readdirSync(currentDir, { withFileTypes: true });
@@ -106,7 +125,12 @@ function enumerateFiles(dirPath, maxDepth = 5) {
106
125
  for (const entry of entries) {
107
126
  const fullPath = join(currentDir, entry.name);
108
127
  if (entry.isDirectory()) {
109
- if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
128
+ if (SKIP_DIRS.has(entry.name)) continue;
129
+ if (WARN_SKIP_DIRS.has(entry.name)) {
130
+ const rel = fullPath.slice(dirPath.length + 1);
131
+ warnings.push(`Skipped directory: ${rel}. May contain unscanned files.`);
132
+ continue;
133
+ }
110
134
  walk(fullPath, depth + 1);
111
135
  continue;
112
136
  }
@@ -120,10 +144,25 @@ function enumerateFiles(dirPath, maxDepth = 5) {
120
144
  const isBinary = BINARY_EXTENSIONS.has(ext);
121
145
  const relativePath = fullPath.slice(dirPath.length + 1);
122
146
  let content;
123
- if (!isBinary && stats.size < 1e6) {
124
- try {
125
- content = readFileSync(fullPath, "utf-8");
126
- } catch {
147
+ if (!isBinary) {
148
+ if (stats.size <= FULL_READ_LIMIT) {
149
+ try {
150
+ content = readFileSync(fullPath, "utf-8");
151
+ } catch {
152
+ }
153
+ } else if (stats.size <= 5e7) {
154
+ try {
155
+ const fd = __require("fs").openSync(fullPath, "r");
156
+ const buf = Buffer.alloc(PARTIAL_READ_LIMIT);
157
+ const bytesRead = __require("fs").readSync(fd, buf, 0, PARTIAL_READ_LIMIT, 0);
158
+ __require("fs").closeSync(fd);
159
+ content = buf.slice(0, bytesRead).toString("utf-8");
160
+ warnings.push(`Large file partially scanned (first ${PARTIAL_READ_LIMIT} bytes): ${relativePath} (${stats.size} bytes total)`);
161
+ } catch {
162
+ warnings.push(`Large file could not be read: ${relativePath} (${stats.size} bytes)`);
163
+ }
164
+ } else {
165
+ warnings.push(`File too large to scan: ${relativePath} (${stats.size} bytes). Content not checked.`);
127
166
  }
128
167
  }
129
168
  files.push({
@@ -247,6 +286,15 @@ var structuralChecks = {
247
286
  });
248
287
  }
249
288
  }
289
+ for (const warning of skill.warnings) {
290
+ results.push({
291
+ id: "STRUCT-008",
292
+ category: "STRUCT",
293
+ severity: "MEDIUM",
294
+ title: "Scan coverage warning",
295
+ message: warning
296
+ });
297
+ }
250
298
  return results;
251
299
  }
252
300
  };
@@ -297,6 +345,14 @@ function isInDocumentationContext(lines, lineIndex) {
297
345
  }
298
346
  return false;
299
347
  }
348
+ function isLicenseFile(filePath) {
349
+ const name = filePath.split("/").pop()?.toUpperCase() ?? "";
350
+ const base = name.replace(/\.[^.]+$/, "");
351
+ return /^(LICENSE|LICENCE|COPYING|NOTICE|AUTHORS|PATENTS)$/.test(base);
352
+ }
353
+ function isLocalhostURL(url) {
354
+ return /^https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])/i.test(url);
355
+ }
300
356
  function parseURLPath(url) {
301
357
  try {
302
358
  const u = new URL(url);
@@ -891,6 +947,66 @@ function dedup(results) {
891
947
  });
892
948
  }
893
949
 
950
+ // src/types.ts
951
+ var SEVERITY_SCORES = {
952
+ CRITICAL: 25,
953
+ HIGH: 10,
954
+ MEDIUM: 3,
955
+ LOW: 1
956
+ };
957
+ function computeGrade(score) {
958
+ if (score >= 90) return "A";
959
+ if (score >= 75) return "B";
960
+ if (score >= 60) return "C";
961
+ if (score >= 40) return "D";
962
+ return "F";
963
+ }
964
+ var REDUCE_MAP = {
965
+ CRITICAL: "HIGH",
966
+ HIGH: "MEDIUM",
967
+ MEDIUM: "LOW",
968
+ LOW: "LOW"
969
+ };
970
+ function reduceSeverity(original, reason) {
971
+ let reduced = REDUCE_MAP[original];
972
+ if (original === "CRITICAL" && reduced === "LOW") {
973
+ reduced = "MEDIUM";
974
+ }
975
+ return {
976
+ severity: reduced,
977
+ reducedFrom: original,
978
+ annotation: `[reduced: ${reason}]`
979
+ };
980
+ }
981
+ var DEFAULT_CONFIG = {
982
+ policy: "balanced",
983
+ overrides: {},
984
+ ignore: []
985
+ };
986
+ function getHookAction(policy, severity) {
987
+ const matrix = {
988
+ strict: {
989
+ CRITICAL: "deny",
990
+ HIGH: "deny",
991
+ MEDIUM: "ask",
992
+ LOW: "report"
993
+ },
994
+ balanced: {
995
+ CRITICAL: "deny",
996
+ HIGH: "ask",
997
+ MEDIUM: "report",
998
+ LOW: "report"
999
+ },
1000
+ permissive: {
1001
+ CRITICAL: "ask",
1002
+ HIGH: "report",
1003
+ MEDIUM: "report",
1004
+ LOW: "report"
1005
+ }
1006
+ };
1007
+ return matrix[policy][severity];
1008
+ }
1009
+
894
1010
  // src/checks/code-safety.ts
895
1011
  var EVAL_PATTERNS = [
896
1012
  /\beval\s*\(/,
@@ -974,6 +1090,7 @@ var codeSafetyChecks = {
974
1090
  const line = lines[i];
975
1091
  const lineNum = i + 1;
976
1092
  const loc = `${source}:${lineNum}`;
1093
+ const cbCtx = { lines, index: i };
977
1094
  checkPatterns(results, line, EVAL_PATTERNS, {
978
1095
  id: "CODE-001",
979
1096
  severity: "CRITICAL",
@@ -995,14 +1112,16 @@ var codeSafetyChecks = {
995
1112
  severity: "CRITICAL",
996
1113
  title: "Destructive file operation",
997
1114
  loc,
998
- lineNum
1115
+ lineNum,
1116
+ codeBlockCtx: cbCtx
999
1117
  });
1000
1118
  checkPatterns(results, line, NETWORK_PATTERNS, {
1001
1119
  id: "CODE-004",
1002
1120
  severity: "HIGH",
1003
1121
  title: "Hardcoded external URL/network request",
1004
1122
  loc,
1005
- lineNum
1123
+ lineNum,
1124
+ codeBlockCtx: cbCtx
1006
1125
  });
1007
1126
  checkPatterns(results, line, FILE_WRITE_PATTERNS, {
1008
1127
  id: "CODE-005",
@@ -1016,7 +1135,8 @@ var codeSafetyChecks = {
1016
1135
  severity: "MEDIUM",
1017
1136
  title: "Environment variable access",
1018
1137
  loc,
1019
- lineNum
1138
+ lineNum,
1139
+ codeBlockCtx: cbCtx
1020
1140
  });
1021
1141
  checkPatterns(results, line, DYNAMIC_CODE_PATTERNS, {
1022
1142
  id: "CODE-010",
@@ -1026,8 +1146,7 @@ var codeSafetyChecks = {
1026
1146
  lineNum
1027
1147
  });
1028
1148
  {
1029
- const srcLines = text.split("\n");
1030
- const isDoc = isInDocumentationContext(srcLines, i);
1149
+ const isDoc = isInDocumentationContext(lines, i);
1031
1150
  if (!isDoc) {
1032
1151
  checkPatterns(results, line, PERMISSION_PATTERNS, {
1033
1152
  id: "CODE-012",
@@ -1048,14 +1167,24 @@ var codeSafetyChecks = {
1048
1167
  function checkPatterns(results, line, patterns, opts) {
1049
1168
  for (const pattern of patterns) {
1050
1169
  if (pattern.test(line)) {
1170
+ let severity = opts.severity;
1171
+ let reducedFrom;
1172
+ let msgSuffix = "";
1173
+ if (opts.codeBlockCtx && isInCodeBlock(opts.codeBlockCtx.lines, opts.codeBlockCtx.index)) {
1174
+ const r = reduceSeverity(severity, "in code block");
1175
+ severity = r.severity;
1176
+ reducedFrom = r.reducedFrom;
1177
+ msgSuffix = ` ${r.annotation}`;
1178
+ }
1051
1179
  results.push({
1052
1180
  id: opts.id,
1053
1181
  category: "CODE",
1054
- severity: opts.severity,
1182
+ severity,
1055
1183
  title: opts.title,
1056
- message: `At ${opts.loc}: ${line.trim().slice(0, 120)}`,
1184
+ message: `At ${opts.loc}: ${line.trim().slice(0, 120)}${msgSuffix}`,
1057
1185
  line: opts.lineNum,
1058
- snippet: line.trim().slice(0, 120)
1186
+ snippet: line.trim().slice(0, 120),
1187
+ reducedFrom
1059
1188
  });
1060
1189
  return;
1061
1190
  }
@@ -1138,22 +1267,144 @@ function scanObfuscation(results, text, source) {
1138
1267
  }
1139
1268
  }
1140
1269
 
1270
+ // src/ioc/index.ts
1271
+ import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
1272
+ import { join as join2 } from "path";
1273
+ import { homedir } from "os";
1274
+
1275
+ // src/ioc/indicators.ts
1276
+ var DEFAULT_IOC = {
1277
+ version: "2026.03.06",
1278
+ updated: "2026-03-06",
1279
+ c2_ips: [
1280
+ "91.92.242.30",
1281
+ "91.92.242.39",
1282
+ "185.220.101.1",
1283
+ "185.220.101.2",
1284
+ "45.155.205.233"
1285
+ ],
1286
+ malicious_hashes: {
1287
+ // NOTE: Never add the SHA-256 of an empty file (e3b0c44298fc...b855)
1288
+ // as it causes false positives on any empty file.
1289
+ "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2": "clawhavoc-exfiltrator"
1290
+ },
1291
+ malicious_domains: [
1292
+ "webhook.site",
1293
+ "requestbin.com",
1294
+ "pipedream.com",
1295
+ "pipedream.net",
1296
+ "hookbin.com",
1297
+ "beeceptor.com",
1298
+ "ngrok.io",
1299
+ "ngrok-free.app",
1300
+ "serveo.net",
1301
+ "localtunnel.me",
1302
+ "bore.pub",
1303
+ "interact.sh",
1304
+ "oast.fun",
1305
+ "oastify.com",
1306
+ "dnslog.cn",
1307
+ "ceye.io",
1308
+ "burpcollaborator.net",
1309
+ "pastebin.com",
1310
+ "paste.ee",
1311
+ "hastebin.com",
1312
+ "ghostbin.com",
1313
+ "evil.com",
1314
+ "malware.com",
1315
+ "exploit.in"
1316
+ ],
1317
+ typosquat: {
1318
+ known_patterns: [
1319
+ "clawhub1",
1320
+ "cllawhub",
1321
+ "clawhab",
1322
+ "moltbot",
1323
+ "claw-hub",
1324
+ "clawhub-pro"
1325
+ ],
1326
+ protected_names: [
1327
+ "clawhub",
1328
+ "secureclaw",
1329
+ "openclaw",
1330
+ "clawbot",
1331
+ "claude",
1332
+ "anthropic",
1333
+ "skill-checker"
1334
+ ]
1335
+ },
1336
+ malicious_publishers: [
1337
+ "clawhavoc",
1338
+ "phantom-tracker",
1339
+ "solana-wallet-drainer"
1340
+ ]
1341
+ };
1342
+
1343
+ // src/ioc/index.ts
1344
+ var cachedIOC = null;
1345
+ function loadIOC() {
1346
+ if (cachedIOC) return cachedIOC;
1347
+ const ioc = structuredClone(DEFAULT_IOC);
1348
+ const overridePath = join2(
1349
+ homedir(),
1350
+ ".config",
1351
+ "skill-checker",
1352
+ "ioc-override.json"
1353
+ );
1354
+ if (existsSync2(overridePath)) {
1355
+ try {
1356
+ const raw = readFileSync2(overridePath, "utf-8");
1357
+ const ext = JSON.parse(raw);
1358
+ mergeIOC(ioc, ext);
1359
+ } catch {
1360
+ }
1361
+ }
1362
+ cachedIOC = ioc;
1363
+ return ioc;
1364
+ }
1365
+ function mergeIOC(base, ext) {
1366
+ if (ext.c2_ips) {
1367
+ base.c2_ips = dedupe([...base.c2_ips, ...ext.c2_ips]);
1368
+ }
1369
+ if (ext.malicious_hashes) {
1370
+ Object.assign(base.malicious_hashes, ext.malicious_hashes);
1371
+ }
1372
+ if (ext.malicious_domains) {
1373
+ base.malicious_domains = dedupe([
1374
+ ...base.malicious_domains,
1375
+ ...ext.malicious_domains
1376
+ ]);
1377
+ }
1378
+ if (ext.typosquat) {
1379
+ if (ext.typosquat.known_patterns) {
1380
+ base.typosquat.known_patterns = dedupe([
1381
+ ...base.typosquat.known_patterns,
1382
+ ...ext.typosquat.known_patterns
1383
+ ]);
1384
+ }
1385
+ if (ext.typosquat.protected_names) {
1386
+ base.typosquat.protected_names = dedupe([
1387
+ ...base.typosquat.protected_names,
1388
+ ...ext.typosquat.protected_names
1389
+ ]);
1390
+ }
1391
+ }
1392
+ if (ext.malicious_publishers) {
1393
+ base.malicious_publishers = dedupe([
1394
+ ...base.malicious_publishers,
1395
+ ...ext.malicious_publishers
1396
+ ]);
1397
+ }
1398
+ if (ext.version) base.version = ext.version;
1399
+ if (ext.updated) base.updated = ext.updated;
1400
+ }
1401
+ function dedupe(arr) {
1402
+ return [...new Set(arr)];
1403
+ }
1404
+
1141
1405
  // src/checks/supply-chain.ts
1142
- var SUSPICIOUS_DOMAINS = [
1143
- "evil.com",
1144
- "malware.com",
1145
- "exploit.in",
1146
- "darkweb.onion",
1147
- "pastebin.com",
1148
- // often used for payload hosting
1149
- "ngrok.io",
1150
- // tunneling service
1151
- "requestbin.com",
1152
- "webhook.site",
1153
- "pipedream.net",
1154
- "burpcollaborator.net",
1155
- "interact.sh",
1156
- "oastify.com"
1406
+ var FALLBACK_SUSPICIOUS_DOMAINS = [
1407
+ "darkweb.onion"
1157
1408
  ];
1158
1409
  var MCP_SERVER_PATTERN = /\bmcp[-_]?server\b/i;
1159
1410
  var NPX_Y_PATTERN = /\bnpx\s+-y\s+/;
@@ -1162,23 +1413,47 @@ var PIP_INSTALL_PATTERN = /\bpip3?\s+install\b/;
1162
1413
  var GIT_CLONE_PATTERN = /\bgit\s+clone\b/;
1163
1414
  var URL_PATTERN = /https?:\/\/[^\s"'`<>)\]]+/g;
1164
1415
  var IP_URL_PATTERN = /https?:\/\/(?:\d{1,3}\.){3}\d{1,3}/;
1416
+ function extractHostname(url) {
1417
+ const afterProto = url.replace(/^https?:\/\//, "");
1418
+ const hostPort = afterProto.split("/")[0].split("?")[0].split("#")[0];
1419
+ const host = hostPort.split(":")[0];
1420
+ return host.toLowerCase();
1421
+ }
1422
+ function hostnameMatchesDomain(hostname, domain) {
1423
+ const d = domain.toLowerCase();
1424
+ return hostname === d || hostname.endsWith("." + d);
1425
+ }
1165
1426
  var supplyChainChecks = {
1166
1427
  name: "Supply Chain",
1167
1428
  category: "SUPPLY",
1168
1429
  run(skill) {
1169
1430
  const results = [];
1170
1431
  const allText = getAllText(skill);
1432
+ const ioc = loadIOC();
1433
+ const suspiciousDomains = ioc.malicious_domains.length > 0 ? ioc.malicious_domains : FALLBACK_SUSPICIOUS_DOMAINS;
1171
1434
  for (let i = 0; i < allText.length; i++) {
1172
1435
  const { line, lineNum, source } = allText[i];
1173
1436
  if (MCP_SERVER_PATTERN.test(line)) {
1437
+ let severity = "HIGH";
1438
+ let reducedFrom;
1439
+ let msgSuffix = "";
1440
+ const srcLines = getLinesForSource(skill, source);
1441
+ const localIdx = getLocalIndex(source, lineNum, skill.bodyStartLine);
1442
+ if (localIdx >= 0 && isInCodeBlock(srcLines, localIdx)) {
1443
+ const r = reduceSeverity(severity, "in code block");
1444
+ severity = r.severity;
1445
+ reducedFrom = r.reducedFrom;
1446
+ msgSuffix = ` ${r.annotation}`;
1447
+ }
1174
1448
  results.push({
1175
1449
  id: "SUPPLY-001",
1176
1450
  category: "SUPPLY",
1177
- severity: "HIGH",
1451
+ severity,
1178
1452
  title: "MCP server reference",
1179
- message: `${source}:${lineNum}: References an MCP server. Verify it is from a trusted source.`,
1453
+ message: `${source}:${lineNum}: References an MCP server. Verify it is from a trusted source.${msgSuffix}`,
1180
1454
  line: lineNum,
1181
- snippet: line.trim().slice(0, 120)
1455
+ snippet: line.trim().slice(0, 120),
1456
+ reducedFrom
1182
1457
  });
1183
1458
  }
1184
1459
  if (NPX_Y_PATTERN.test(line)) {
@@ -1200,14 +1475,26 @@ var supplyChainChecks = {
1200
1475
  globalIdx
1201
1476
  );
1202
1477
  if (!isDoc) {
1478
+ let severity = "HIGH";
1479
+ let reducedFrom;
1480
+ let msgSuffix = "";
1481
+ const srcLines = getLinesForSource(skill, source);
1482
+ const localIdx = getLocalIndex(source, lineNum, skill.bodyStartLine);
1483
+ if (localIdx >= 0 && isInCodeBlock(srcLines, localIdx)) {
1484
+ const r = reduceSeverity(severity, "in code block");
1485
+ severity = r.severity;
1486
+ reducedFrom = r.reducedFrom;
1487
+ msgSuffix = ` ${r.annotation}`;
1488
+ }
1203
1489
  results.push({
1204
1490
  id: "SUPPLY-003",
1205
1491
  category: "SUPPLY",
1206
- severity: "HIGH",
1492
+ severity,
1207
1493
  title: "Package installation command",
1208
- message: `${source}:${lineNum}: Installs packages. Verify package names are legitimate.`,
1494
+ message: `${source}:${lineNum}: Installs packages. Verify package names are legitimate.${msgSuffix}`,
1209
1495
  line: lineNum,
1210
- snippet: line.trim().slice(0, 120)
1496
+ snippet: line.trim().slice(0, 120),
1497
+ reducedFrom
1211
1498
  });
1212
1499
  }
1213
1500
  }
@@ -1225,16 +1512,30 @@ var supplyChainChecks = {
1225
1512
  const urls = line.match(URL_PATTERN) || [];
1226
1513
  for (const url of urls) {
1227
1514
  if (url.startsWith("http://")) {
1515
+ if (isLicenseFile(source)) continue;
1516
+ if (isLocalhostURL(url)) continue;
1228
1517
  if (!isNamespaceOrSchemaURI(url, line)) {
1229
1518
  const isNetworkCtx = isInNetworkRequestContext(line);
1519
+ let severity = isNetworkCtx ? "HIGH" : "MEDIUM";
1520
+ let reducedFrom;
1521
+ let msgSuffix = "";
1522
+ const srcLines = getLinesForSource(skill, source);
1523
+ const localIdx = getLocalIndex(source, lineNum, skill.bodyStartLine);
1524
+ if (localIdx >= 0 && isInCodeBlock(srcLines, localIdx)) {
1525
+ const r = reduceSeverity(severity, "in code block");
1526
+ severity = r.severity;
1527
+ reducedFrom = r.reducedFrom;
1528
+ msgSuffix = ` ${r.annotation}`;
1529
+ }
1230
1530
  results.push({
1231
1531
  id: "SUPPLY-004",
1232
1532
  category: "SUPPLY",
1233
- severity: isNetworkCtx ? "HIGH" : "MEDIUM",
1533
+ severity,
1234
1534
  title: "Non-HTTPS URL",
1235
- message: `${source}:${lineNum}: Uses insecure HTTP: ${url}`,
1535
+ message: `${source}:${lineNum}: Uses insecure HTTP: ${url}${msgSuffix}`,
1236
1536
  line: lineNum,
1237
- snippet: url
1537
+ snippet: url,
1538
+ reducedFrom
1238
1539
  });
1239
1540
  }
1240
1541
  }
@@ -1251,8 +1552,9 @@ var supplyChainChecks = {
1251
1552
  });
1252
1553
  }
1253
1554
  }
1254
- for (const domain of SUSPICIOUS_DOMAINS) {
1255
- if (url.includes(domain)) {
1555
+ const hostname = extractHostname(url);
1556
+ for (const domain of suspiciousDomains) {
1557
+ if (hostnameMatchesDomain(hostname, domain)) {
1256
1558
  results.push({
1257
1559
  id: "SUPPLY-007",
1258
1560
  category: "SUPPLY",
@@ -1270,6 +1572,15 @@ var supplyChainChecks = {
1270
1572
  return results;
1271
1573
  }
1272
1574
  };
1575
+ function getLinesForSource(skill, source) {
1576
+ if (source === "SKILL.md") return skill.bodyLines;
1577
+ const file = skill.files.find((f) => f.path === source);
1578
+ return file?.content?.split("\n") ?? [];
1579
+ }
1580
+ function getLocalIndex(source, lineNum, bodyStartLine) {
1581
+ if (source === "SKILL.md") return lineNum - bodyStartLine;
1582
+ return lineNum - 1;
1583
+ }
1273
1584
  function getAllText(skill) {
1274
1585
  const result = [];
1275
1586
  for (let i = 0; i < skill.bodyLines.length; i++) {
@@ -1449,143 +1760,9 @@ var resourceChecks = {
1449
1760
  }
1450
1761
  };
1451
1762
 
1452
- // src/ioc/index.ts
1453
- import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
1454
- import { join as join2 } from "path";
1455
- import { homedir } from "os";
1456
-
1457
- // src/ioc/indicators.ts
1458
- var DEFAULT_IOC = {
1459
- version: "2026.03.06",
1460
- updated: "2026-03-06",
1461
- c2_ips: [
1462
- "91.92.242.30",
1463
- "91.92.242.39",
1464
- "185.220.101.1",
1465
- "185.220.101.2",
1466
- "45.155.205.233"
1467
- ],
1468
- malicious_hashes: {
1469
- "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855": "clawhavoc-empty-payload",
1470
- "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2": "clawhavoc-exfiltrator"
1471
- },
1472
- malicious_domains: [
1473
- "webhook.site",
1474
- "requestbin.com",
1475
- "pipedream.com",
1476
- "pipedream.net",
1477
- "hookbin.com",
1478
- "beeceptor.com",
1479
- "ngrok.io",
1480
- "ngrok-free.app",
1481
- "serveo.net",
1482
- "localtunnel.me",
1483
- "bore.pub",
1484
- "interact.sh",
1485
- "oast.fun",
1486
- "oastify.com",
1487
- "dnslog.cn",
1488
- "ceye.io",
1489
- "burpcollaborator.net",
1490
- "pastebin.com",
1491
- "paste.ee",
1492
- "hastebin.com",
1493
- "ghostbin.com",
1494
- "evil.com",
1495
- "malware.com",
1496
- "exploit.in"
1497
- ],
1498
- typosquat: {
1499
- known_patterns: [
1500
- "clawhub1",
1501
- "cllawhub",
1502
- "clawhab",
1503
- "moltbot",
1504
- "claw-hub",
1505
- "clawhub-pro"
1506
- ],
1507
- protected_names: [
1508
- "clawhub",
1509
- "secureclaw",
1510
- "openclaw",
1511
- "clawbot",
1512
- "claude",
1513
- "anthropic",
1514
- "skill-checker"
1515
- ]
1516
- },
1517
- malicious_publishers: [
1518
- "clawhavoc",
1519
- "phantom-tracker",
1520
- "solana-wallet-drainer"
1521
- ]
1522
- };
1523
-
1524
- // src/ioc/index.ts
1525
- var cachedIOC = null;
1526
- function loadIOC() {
1527
- if (cachedIOC) return cachedIOC;
1528
- const ioc = structuredClone(DEFAULT_IOC);
1529
- const overridePath = join2(
1530
- homedir(),
1531
- ".config",
1532
- "skill-checker",
1533
- "ioc-override.json"
1534
- );
1535
- if (existsSync2(overridePath)) {
1536
- try {
1537
- const raw = readFileSync2(overridePath, "utf-8");
1538
- const ext = JSON.parse(raw);
1539
- mergeIOC(ioc, ext);
1540
- } catch {
1541
- }
1542
- }
1543
- cachedIOC = ioc;
1544
- return ioc;
1545
- }
1546
- function mergeIOC(base, ext) {
1547
- if (ext.c2_ips) {
1548
- base.c2_ips = dedupe([...base.c2_ips, ...ext.c2_ips]);
1549
- }
1550
- if (ext.malicious_hashes) {
1551
- Object.assign(base.malicious_hashes, ext.malicious_hashes);
1552
- }
1553
- if (ext.malicious_domains) {
1554
- base.malicious_domains = dedupe([
1555
- ...base.malicious_domains,
1556
- ...ext.malicious_domains
1557
- ]);
1558
- }
1559
- if (ext.typosquat) {
1560
- if (ext.typosquat.known_patterns) {
1561
- base.typosquat.known_patterns = dedupe([
1562
- ...base.typosquat.known_patterns,
1563
- ...ext.typosquat.known_patterns
1564
- ]);
1565
- }
1566
- if (ext.typosquat.protected_names) {
1567
- base.typosquat.protected_names = dedupe([
1568
- ...base.typosquat.protected_names,
1569
- ...ext.typosquat.protected_names
1570
- ]);
1571
- }
1572
- }
1573
- if (ext.malicious_publishers) {
1574
- base.malicious_publishers = dedupe([
1575
- ...base.malicious_publishers,
1576
- ...ext.malicious_publishers
1577
- ]);
1578
- }
1579
- if (ext.version) base.version = ext.version;
1580
- if (ext.updated) base.updated = ext.updated;
1581
- }
1582
- function dedupe(arr) {
1583
- return [...new Set(arr)];
1584
- }
1585
-
1586
1763
  // src/ioc/matcher.ts
1587
1764
  import { createHash } from "crypto";
1588
- import { readFileSync as readFileSync3 } from "fs";
1765
+ import { openSync, readSync, closeSync, statSync as statSync2 } from "fs";
1589
1766
  import { join as join3 } from "path";
1590
1767
 
1591
1768
  // src/utils/levenshtein.ts
@@ -1618,6 +1795,7 @@ function levenshtein(a, b) {
1618
1795
  }
1619
1796
 
1620
1797
  // src/ioc/matcher.ts
1798
+ var EMPTY_FILE_HASH = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
1621
1799
  var IPV4_PATTERN = /\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/g;
1622
1800
  function isPrivateIP(ip) {
1623
1801
  const parts = ip.split(".").map(Number);
@@ -1630,6 +1808,24 @@ function isPrivateIP(ip) {
1630
1808
  if (parts[0] === 169 && parts[1] === 254) return true;
1631
1809
  return false;
1632
1810
  }
1811
+ var HASH_CHUNK_SIZE = 64 * 1024;
1812
+ function computeFileHash(filePath) {
1813
+ const hash = createHash("sha256");
1814
+ const fd = openSync(filePath, "r");
1815
+ try {
1816
+ const buf = Buffer.alloc(HASH_CHUNK_SIZE);
1817
+ let bytesRead;
1818
+ do {
1819
+ bytesRead = readSync(fd, buf, 0, HASH_CHUNK_SIZE, null);
1820
+ if (bytesRead > 0) {
1821
+ hash.update(buf.subarray(0, bytesRead));
1822
+ }
1823
+ } while (bytesRead > 0);
1824
+ } finally {
1825
+ closeSync(fd);
1826
+ }
1827
+ return hash.digest("hex");
1828
+ }
1633
1829
  function matchMaliciousHashes(skill, ioc) {
1634
1830
  const matches = [];
1635
1831
  const hashKeys = Object.keys(ioc.malicious_hashes);
@@ -1637,8 +1833,10 @@ function matchMaliciousHashes(skill, ioc) {
1637
1833
  for (const file of skill.files) {
1638
1834
  const filePath = join3(skill.dirPath, file.path);
1639
1835
  try {
1640
- const content = readFileSync3(filePath);
1641
- const hash = createHash("sha256").update(content).digest("hex");
1836
+ const stat = statSync2(filePath);
1837
+ if (stat.size === 0) continue;
1838
+ const hash = computeFileHash(filePath);
1839
+ if (hash === EMPTY_FILE_HASH) continue;
1642
1840
  if (ioc.malicious_hashes[hash]) {
1643
1841
  matches.push({
1644
1842
  file: file.path,
@@ -1788,49 +1986,6 @@ function runAllChecks(skill) {
1788
1986
  return results;
1789
1987
  }
1790
1988
 
1791
- // src/types.ts
1792
- var SEVERITY_SCORES = {
1793
- CRITICAL: 25,
1794
- HIGH: 10,
1795
- MEDIUM: 3,
1796
- LOW: 1
1797
- };
1798
- function computeGrade(score) {
1799
- if (score >= 90) return "A";
1800
- if (score >= 75) return "B";
1801
- if (score >= 60) return "C";
1802
- if (score >= 40) return "D";
1803
- return "F";
1804
- }
1805
- var DEFAULT_CONFIG = {
1806
- policy: "balanced",
1807
- overrides: {},
1808
- ignore: []
1809
- };
1810
- function getHookAction(policy, severity) {
1811
- const matrix = {
1812
- strict: {
1813
- CRITICAL: "deny",
1814
- HIGH: "deny",
1815
- MEDIUM: "ask",
1816
- LOW: "report"
1817
- },
1818
- balanced: {
1819
- CRITICAL: "deny",
1820
- HIGH: "ask",
1821
- MEDIUM: "report",
1822
- LOW: "report"
1823
- },
1824
- permissive: {
1825
- CRITICAL: "ask",
1826
- HIGH: "report",
1827
- MEDIUM: "report",
1828
- LOW: "report"
1829
- }
1830
- };
1831
- return matrix[policy][severity];
1832
- }
1833
-
1834
1989
  // src/scanner.ts
1835
1990
  function scanSkillDirectory(dirPath, config = DEFAULT_CONFIG) {
1836
1991
  const skill = parseSkill(dirPath);
@@ -1845,6 +2000,7 @@ function buildReport(skill, config) {
1845
2000
  return r;
1846
2001
  });
1847
2002
  results = results.filter((r) => !config.ignore.includes(r.id));
2003
+ results = deduplicateResults(results);
1848
2004
  const score = calculateScore(results);
1849
2005
  const grade = computeGrade(score);
1850
2006
  const summary = {
@@ -1864,6 +2020,40 @@ function buildReport(skill, config) {
1864
2020
  summary
1865
2021
  };
1866
2022
  }
2023
+ function deduplicateResults(results) {
2024
+ const groups = /* @__PURE__ */ new Map();
2025
+ const severityOrder = {
2026
+ CRITICAL: 4,
2027
+ HIGH: 3,
2028
+ MEDIUM: 2,
2029
+ LOW: 1
2030
+ };
2031
+ for (const r of results) {
2032
+ const sourceFile = extractSourceFile(r.message);
2033
+ const key = `${r.id}::${sourceFile}`;
2034
+ const group = groups.get(key);
2035
+ if (group) {
2036
+ group.push(r);
2037
+ } else {
2038
+ groups.set(key, [r]);
2039
+ }
2040
+ }
2041
+ const deduped = [];
2042
+ for (const group of groups.values()) {
2043
+ group.sort((a, b) => severityOrder[b.severity] - severityOrder[a.severity]);
2044
+ const best = { ...group[0] };
2045
+ if (group.length > 1) {
2046
+ best.occurrences = group.length;
2047
+ best.message += ` (${group.length} occurrences in this file)`;
2048
+ }
2049
+ deduped.push(best);
2050
+ }
2051
+ return deduped;
2052
+ }
2053
+ function extractSourceFile(message) {
2054
+ const m = message.match(/^(?:At\s+)?([^:]+):\d+:/);
2055
+ return m?.[1] ?? "unknown";
2056
+ }
1867
2057
  function calculateScore(results) {
1868
2058
  let score = 100;
1869
2059
  for (const r of results) {
@@ -2050,7 +2240,7 @@ function buildReportSummary(report) {
2050
2240
  }
2051
2241
 
2052
2242
  // src/config.ts
2053
- import { readFileSync as readFileSync4, existsSync as existsSync3 } from "fs";
2243
+ import { readFileSync as readFileSync3, existsSync as existsSync3 } from "fs";
2054
2244
  import { join as join4, resolve as resolve2 } from "path";
2055
2245
  import { parse as parseYaml2 } from "yaml";
2056
2246
  var CONFIG_FILENAMES = [
@@ -2092,7 +2282,7 @@ function loadConfig(startDir, configPath) {
2092
2282
  }
2093
2283
  function parseConfigFile(path) {
2094
2284
  try {
2095
- const raw = readFileSync4(path, "utf-8");
2285
+ const raw = readFileSync3(path, "utf-8");
2096
2286
  const parsed = parseYaml2(raw);
2097
2287
  if (!parsed || typeof parsed !== "object") {
2098
2288
  return { ...DEFAULT_CONFIG };
@@ -2132,10 +2322,12 @@ function normalizeSeverity(value) {
2132
2322
  }
2133
2323
 
2134
2324
  // src/cli.ts
2325
+ var require2 = createRequire(import.meta.url);
2326
+ var pkg = require2("../package.json");
2135
2327
  var program = new Command();
2136
2328
  program.name("skill-checker").description(
2137
2329
  "Security checker for Claude Code skills - detect injection, malicious code, and supply chain risks"
2138
- ).version("0.1.0");
2330
+ ).version(pkg.version);
2139
2331
  program.command("scan").description("Scan a skill directory for security issues").argument("<path>", "Path to the skill directory").option("-f, --format <format>", "Output format: terminal, json, hook", "terminal").option("-p, --policy <policy>", "Policy: strict, balanced, permissive").option("-c, --config <path>", "Path to config file").action(
2140
2332
  (path, opts) => {
2141
2333
  const config = loadConfig(path, opts.config);