skill-checker 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +144 -14
- package/dist/cli.js +94 -56
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +89 -56
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,49 +4,179 @@ Security checker for Claude Code skills — detect injection, malicious code, an
|
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
- **
|
|
7
|
+
- **52 security rules** across 6 categories: structural validity, content quality, injection detection, code safety, supply chain, and resource abuse
|
|
8
8
|
- **Scoring system**: Grade A–F with 0–100 score
|
|
9
9
|
- **Dual entry**: CLI tool + PreToolUse hook for automatic interception
|
|
10
10
|
- **Configurable policies**: strict / balanced / permissive approval strategies
|
|
11
|
+
- **Context-aware detection**: severity reduction in code blocks and documentation sections, with zero reduction for injection rules
|
|
12
|
+
- **IOC threat intelligence**: built-in seed data for known malicious hashes, C2 IPs, and typosquat names
|
|
11
13
|
- **Multiple output formats**: terminal (color), JSON, hook response
|
|
12
14
|
|
|
15
|
+
## Security Standard & Benchmark
|
|
16
|
+
|
|
17
|
+
Skill Checker's 52 rules are aligned with established security frameworks including OWASP Top 10 for LLM Applications (2025), MITRE CWE, and MITRE ATT&CK. The tool ships with a reproducible benchmark dataset of six fixture skills covering all rule categories. This alignment is an internal mapping exercise — Skill Checker does not claim third-party certification or external audit status.
|
|
18
|
+
|
|
19
|
+
See [docs/SECURITY_BENCHMARK.md](docs/SECURITY_BENCHMARK.md) for the full rule mapping matrix, benchmark methodology, scoring model, and known limitations.
|
|
20
|
+
|
|
13
21
|
## Quick Start
|
|
14
22
|
|
|
15
23
|
```bash
|
|
24
|
+
# Install globally
|
|
25
|
+
npm install -g skill-checker
|
|
26
|
+
|
|
16
27
|
# Scan a skill directory
|
|
28
|
+
skill-checker scan ./path/to/skill/
|
|
29
|
+
|
|
30
|
+
# Or run without installing
|
|
17
31
|
npx skill-checker scan ./path/to/skill/
|
|
32
|
+
```
|
|
18
33
|
|
|
19
|
-
|
|
20
|
-
npx skill-checker scan ./path/to/skill/ --format json
|
|
34
|
+
## Usage
|
|
21
35
|
|
|
22
|
-
|
|
23
|
-
|
|
36
|
+
```bash
|
|
37
|
+
skill-checker scan <path> [options]
|
|
24
38
|
```
|
|
25
39
|
|
|
26
|
-
|
|
40
|
+
| Option | Description |
|
|
41
|
+
|--------|-------------|
|
|
42
|
+
| `-f, --format <format>` | Output format: `terminal` (default), `json`, `hook` |
|
|
43
|
+
| `-p, --policy <policy>` | Approval policy: `strict`, `balanced` (default), `permissive` |
|
|
44
|
+
| `-c, --config <path>` | Path to config file |
|
|
27
45
|
|
|
28
46
|
```bash
|
|
29
|
-
|
|
47
|
+
# Colored terminal report
|
|
48
|
+
skill-checker scan ./my-skill
|
|
49
|
+
|
|
50
|
+
# JSON output for CI/programmatic use
|
|
51
|
+
skill-checker scan ./my-skill --format json
|
|
52
|
+
|
|
53
|
+
# Hook response format (for PreToolUse integration)
|
|
54
|
+
skill-checker scan ./my-skill --format hook
|
|
55
|
+
|
|
56
|
+
# Strict policy — deny on HIGH and above
|
|
57
|
+
skill-checker scan ./my-skill --policy strict
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Exit code `0` = no critical issues, `1` = critical issues detected.
|
|
61
|
+
|
|
62
|
+
## Hook Integration
|
|
63
|
+
|
|
64
|
+
Skill Checker can run automatically as a Claude Code [PreToolUse hook](https://docs.anthropic.com/en/docs/claude-code/hooks), intercepting skill file writes before they happen.
|
|
65
|
+
|
|
66
|
+
### Setup
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
npx tsx hook/install.ts
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
This adds a hook entry to `~/.claude/settings.json`:
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"hooks": {
|
|
77
|
+
"PreToolUse": [
|
|
78
|
+
{
|
|
79
|
+
"matcher": "Write|Edit",
|
|
80
|
+
"hook": "/path/to/skill-gate.sh"
|
|
81
|
+
}
|
|
82
|
+
]
|
|
83
|
+
}
|
|
84
|
+
}
|
|
30
85
|
```
|
|
31
86
|
|
|
87
|
+
### How It Works
|
|
88
|
+
|
|
89
|
+
1. Claude Code intercepts Write/Edit operations targeting SKILL.md files
|
|
90
|
+
2. `skill-gate.sh` receives the file content via stdin (JSON)
|
|
91
|
+
3. Runs `skill-checker scan --format hook` on the content
|
|
92
|
+
4. Returns a permission decision: `allow`, `ask`, or `deny`
|
|
93
|
+
|
|
94
|
+
The hook is fail-closed — if the scanner is unavailable, JSON parsing fails, or any unexpected error occurs, it returns `ask` (never silently allows).
|
|
95
|
+
|
|
96
|
+
### Requirements
|
|
97
|
+
|
|
98
|
+
- `jq` must be installed for JSON parsing
|
|
99
|
+
- `skill-checker` must be globally installed or available via `npx`
|
|
100
|
+
|
|
101
|
+
## Scoring
|
|
102
|
+
|
|
103
|
+
Base score starts at **100**. Each finding deducts points by severity:
|
|
104
|
+
|
|
105
|
+
| Severity | Deduction |
|
|
106
|
+
|----------|-----------|
|
|
107
|
+
| CRITICAL | -25 |
|
|
108
|
+
| HIGH | -10 |
|
|
109
|
+
| MEDIUM | -3 |
|
|
110
|
+
| LOW | -1 |
|
|
111
|
+
|
|
112
|
+
| Grade | Score | Meaning |
|
|
113
|
+
|-------|-------|---------|
|
|
114
|
+
| A | 90–100 | Safe to install |
|
|
115
|
+
| B | 75–89 | Minor issues |
|
|
116
|
+
| C | 60–74 | Review advised |
|
|
117
|
+
| D | 40–59 | Significant risk |
|
|
118
|
+
| F | 0–39 | Not recommended |
|
|
119
|
+
|
|
32
120
|
## Configuration
|
|
33
121
|
|
|
34
|
-
Create
|
|
122
|
+
Create `.skillcheckerrc.yaml` in your project root or home directory:
|
|
35
123
|
|
|
36
124
|
```yaml
|
|
37
|
-
policy
|
|
125
|
+
# Approval policy
|
|
126
|
+
policy: balanced # strict / balanced / permissive
|
|
38
127
|
|
|
128
|
+
# Override severity for specific rules
|
|
39
129
|
overrides:
|
|
40
|
-
CODE-006: LOW
|
|
130
|
+
CODE-006: LOW # env var access is expected in my skills
|
|
131
|
+
SUPPLY-002: LOW # I trust npx -y in my workflow
|
|
41
132
|
|
|
133
|
+
# Ignore rules entirely
|
|
42
134
|
ignore:
|
|
43
|
-
- CONT-006
|
|
135
|
+
- CONT-006 # reference-heavy skills are fine
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Config is resolved in order: CLI `--config` flag → project directory (walks up) → home directory → defaults.
|
|
139
|
+
|
|
140
|
+
### Policy Matrix
|
|
141
|
+
|
|
142
|
+
| Severity | strict | balanced | permissive |
|
|
143
|
+
|----------|--------|----------|------------|
|
|
144
|
+
| CRITICAL | deny | deny | ask |
|
|
145
|
+
| HIGH | deny | ask | report |
|
|
146
|
+
| MEDIUM | ask | report | report |
|
|
147
|
+
| LOW | report | report | report |
|
|
148
|
+
|
|
149
|
+
## Rule Categories
|
|
150
|
+
|
|
151
|
+
| Category | Rules | Examples |
|
|
152
|
+
|----------|-------|---------|
|
|
153
|
+
| Structural (STRUCT) | 8 | Missing SKILL.md, invalid frontmatter, binary files |
|
|
154
|
+
| Content (CONT) | 7 | Placeholder text, lorem ipsum, promotional content |
|
|
155
|
+
| Injection (INJ) | 9 | Zero-width chars, prompt override, tag injection, encoded payloads |
|
|
156
|
+
| Code Safety (CODE) | 12 | eval/exec, shell execution, rm -rf, obfuscation |
|
|
157
|
+
| Supply Chain (SUPPLY) | 10 | Unknown MCP servers, suspicious domains, malicious hashes, typosquat |
|
|
158
|
+
| Resource Abuse (RES) | 6 | Unrestricted tool access, disable safety checks, ignore project rules |
|
|
159
|
+
|
|
160
|
+
See [docs/SECURITY_BENCHMARK.md](docs/SECURITY_BENCHMARK.md) for the complete rule mapping with OWASP/CWE/ATT&CK references.
|
|
161
|
+
|
|
162
|
+
## Programmatic API
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
import { scanSkillDirectory } from 'skill-checker';
|
|
166
|
+
|
|
167
|
+
const report = scanSkillDirectory('./my-skill', {
|
|
168
|
+
policy: 'strict',
|
|
169
|
+
overrides: { 'CODE-006': 'LOW' },
|
|
170
|
+
ignore: ['CONT-001'],
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
console.log(report.grade, report.score, report.results.length);
|
|
44
174
|
```
|
|
45
175
|
|
|
46
176
|
## License
|
|
47
177
|
|
|
48
|
-
This project is licensed under the **GNU Affero General Public License v3.0 (AGPLv3)**
|
|
178
|
+
This project is licensed under the **GNU Affero General Public License v3.0 (AGPLv3)** — see the [LICENSE](LICENSE) file for details.
|
|
49
179
|
|
|
50
|
-
|
|
180
|
+
**Commercial License (商业授权)**
|
|
51
181
|
|
|
52
|
-
|
|
182
|
+
If you want to integrate this tool into a closed-source commercial product or SaaS, or cannot comply with AGPLv3 due to company policy, contact Alexander.kinging@gmail.com for a commercial license.
|
package/dist/cli.js
CHANGED
|
@@ -1,16 +1,9 @@
|
|
|
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
|
-
|
|
8
1
|
// src/cli.ts
|
|
9
2
|
import { createRequire } from "module";
|
|
10
3
|
import { Command } from "commander";
|
|
11
4
|
|
|
12
5
|
// src/parser.ts
|
|
13
|
-
import { readFileSync, readdirSync,
|
|
6
|
+
import { readFileSync, readdirSync, lstatSync, existsSync, openSync, readSync, closeSync } from "fs";
|
|
14
7
|
import { join, extname, basename, resolve } from "path";
|
|
15
8
|
import { parse as parseYaml } from "yaml";
|
|
16
9
|
var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
@@ -124,52 +117,66 @@ function enumerateFiles(dirPath, warnings) {
|
|
|
124
117
|
}
|
|
125
118
|
for (const entry of entries) {
|
|
126
119
|
const fullPath = join(currentDir, entry.name);
|
|
127
|
-
|
|
120
|
+
const relativePath = fullPath.slice(dirPath.length + 1);
|
|
121
|
+
let lstats;
|
|
122
|
+
try {
|
|
123
|
+
lstats = lstatSync(fullPath);
|
|
124
|
+
} catch {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (lstats.isSymbolicLink()) {
|
|
128
|
+
warnings.push(`Skipped symlink: ${relativePath}`);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (lstats.isDirectory()) {
|
|
128
132
|
if (SKIP_DIRS.has(entry.name)) continue;
|
|
129
133
|
if (WARN_SKIP_DIRS.has(entry.name)) {
|
|
130
|
-
|
|
131
|
-
warnings.push(`Skipped directory: ${rel}. May contain unscanned files.`);
|
|
134
|
+
warnings.push(`Skipped directory: ${relativePath}. May contain unscanned files.`);
|
|
132
135
|
continue;
|
|
133
136
|
}
|
|
134
137
|
walk(fullPath, depth + 1);
|
|
135
138
|
continue;
|
|
136
139
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
try {
|
|
140
|
-
stats = statSync(fullPath);
|
|
141
|
-
} catch {
|
|
140
|
+
if (!lstats.isFile()) {
|
|
141
|
+
warnings.push(`Skipped special file: ${relativePath}`);
|
|
142
142
|
continue;
|
|
143
143
|
}
|
|
144
|
+
const ext = extname(entry.name).toLowerCase();
|
|
144
145
|
const isBinary = BINARY_EXTENSIONS.has(ext);
|
|
145
|
-
const relativePath = fullPath.slice(dirPath.length + 1);
|
|
146
146
|
let content;
|
|
147
147
|
if (!isBinary) {
|
|
148
|
-
if (
|
|
148
|
+
if (lstats.size <= FULL_READ_LIMIT) {
|
|
149
149
|
try {
|
|
150
150
|
content = readFileSync(fullPath, "utf-8");
|
|
151
151
|
} catch {
|
|
152
152
|
}
|
|
153
|
-
} else if (
|
|
153
|
+
} else if (lstats.size <= 5e7) {
|
|
154
|
+
let fd;
|
|
154
155
|
try {
|
|
155
|
-
|
|
156
|
+
fd = openSync(fullPath, "r");
|
|
156
157
|
const buf = Buffer.alloc(PARTIAL_READ_LIMIT);
|
|
157
|
-
const bytesRead =
|
|
158
|
-
__require("fs").closeSync(fd);
|
|
158
|
+
const bytesRead = readSync(fd, buf, 0, PARTIAL_READ_LIMIT, 0);
|
|
159
159
|
content = buf.slice(0, bytesRead).toString("utf-8");
|
|
160
|
-
warnings.push(`Large file partially scanned (first ${PARTIAL_READ_LIMIT} bytes): ${relativePath} (${
|
|
160
|
+
warnings.push(`Large file partially scanned (first ${PARTIAL_READ_LIMIT} bytes): ${relativePath} (${lstats.size} bytes total)`);
|
|
161
161
|
} catch {
|
|
162
|
-
warnings.push(`Large file could not be read: ${relativePath} (${
|
|
162
|
+
warnings.push(`Large file could not be read: ${relativePath} (${lstats.size} bytes)`);
|
|
163
|
+
} finally {
|
|
164
|
+
if (fd !== void 0) {
|
|
165
|
+
try {
|
|
166
|
+
closeSync(fd);
|
|
167
|
+
} catch {
|
|
168
|
+
}
|
|
169
|
+
}
|
|
163
170
|
}
|
|
164
171
|
} else {
|
|
165
|
-
warnings.push(`File too large to scan: ${relativePath} (${
|
|
172
|
+
warnings.push(`File too large to scan: ${relativePath} (${lstats.size} bytes). Content not checked.`);
|
|
166
173
|
}
|
|
167
174
|
}
|
|
168
175
|
files.push({
|
|
169
176
|
path: relativePath,
|
|
170
177
|
name: basename(entry.name, ext),
|
|
171
178
|
extension: ext,
|
|
172
|
-
sizeBytes:
|
|
179
|
+
sizeBytes: lstats.size,
|
|
173
180
|
isBinary,
|
|
174
181
|
content
|
|
175
182
|
});
|
|
@@ -261,7 +268,8 @@ var structuralChecks = {
|
|
|
261
268
|
category: "STRUCT",
|
|
262
269
|
severity: "HIGH",
|
|
263
270
|
title: "Unexpected binary/executable file",
|
|
264
|
-
message: `Found unexpected file: ${file.path} (${ext})
|
|
271
|
+
message: `Found unexpected file: ${file.path} (${ext})`,
|
|
272
|
+
source: file.path
|
|
265
273
|
});
|
|
266
274
|
}
|
|
267
275
|
}
|
|
@@ -287,12 +295,14 @@ var structuralChecks = {
|
|
|
287
295
|
}
|
|
288
296
|
}
|
|
289
297
|
for (const warning of skill.warnings) {
|
|
298
|
+
const pathMatch = warning.match(/:\s*(.+?)(?:\s*\(|$)/);
|
|
290
299
|
results.push({
|
|
291
300
|
id: "STRUCT-008",
|
|
292
301
|
category: "STRUCT",
|
|
293
302
|
severity: "MEDIUM",
|
|
294
303
|
title: "Scan coverage warning",
|
|
295
|
-
message: warning
|
|
304
|
+
message: warning,
|
|
305
|
+
source: pathMatch?.[1]?.trim()
|
|
296
306
|
});
|
|
297
307
|
}
|
|
298
308
|
return results;
|
|
@@ -1004,7 +1014,11 @@ function getHookAction(policy, severity) {
|
|
|
1004
1014
|
LOW: "report"
|
|
1005
1015
|
}
|
|
1006
1016
|
};
|
|
1007
|
-
|
|
1017
|
+
const row = matrix[policy];
|
|
1018
|
+
if (!row) {
|
|
1019
|
+
return matrix.balanced[severity];
|
|
1020
|
+
}
|
|
1021
|
+
return row[severity];
|
|
1008
1022
|
}
|
|
1009
1023
|
|
|
1010
1024
|
// src/checks/code-safety.ts
|
|
@@ -1096,7 +1110,8 @@ var codeSafetyChecks = {
|
|
|
1096
1110
|
severity: "CRITICAL",
|
|
1097
1111
|
title: "eval/exec/Function constructor",
|
|
1098
1112
|
loc,
|
|
1099
|
-
lineNum
|
|
1113
|
+
lineNum,
|
|
1114
|
+
source
|
|
1100
1115
|
});
|
|
1101
1116
|
if (!SHELL_EXEC_FALSE_POSITIVES.some((p) => p.test(line))) {
|
|
1102
1117
|
checkPatterns(results, line, SHELL_EXEC_PATTERNS, {
|
|
@@ -1104,7 +1119,8 @@ var codeSafetyChecks = {
|
|
|
1104
1119
|
severity: "CRITICAL",
|
|
1105
1120
|
title: "Shell/subprocess execution",
|
|
1106
1121
|
loc,
|
|
1107
|
-
lineNum
|
|
1122
|
+
lineNum,
|
|
1123
|
+
source
|
|
1108
1124
|
});
|
|
1109
1125
|
}
|
|
1110
1126
|
checkPatterns(results, line, DESTRUCTIVE_PATTERNS, {
|
|
@@ -1113,6 +1129,7 @@ var codeSafetyChecks = {
|
|
|
1113
1129
|
title: "Destructive file operation",
|
|
1114
1130
|
loc,
|
|
1115
1131
|
lineNum,
|
|
1132
|
+
source,
|
|
1116
1133
|
codeBlockCtx: cbCtx
|
|
1117
1134
|
});
|
|
1118
1135
|
checkPatterns(results, line, NETWORK_PATTERNS, {
|
|
@@ -1121,6 +1138,7 @@ var codeSafetyChecks = {
|
|
|
1121
1138
|
title: "Hardcoded external URL/network request",
|
|
1122
1139
|
loc,
|
|
1123
1140
|
lineNum,
|
|
1141
|
+
source,
|
|
1124
1142
|
codeBlockCtx: cbCtx
|
|
1125
1143
|
});
|
|
1126
1144
|
checkPatterns(results, line, FILE_WRITE_PATTERNS, {
|
|
@@ -1128,7 +1146,8 @@ var codeSafetyChecks = {
|
|
|
1128
1146
|
severity: "HIGH",
|
|
1129
1147
|
title: "File write outside expected directory",
|
|
1130
1148
|
loc,
|
|
1131
|
-
lineNum
|
|
1149
|
+
lineNum,
|
|
1150
|
+
source
|
|
1132
1151
|
});
|
|
1133
1152
|
checkPatterns(results, line, ENV_ACCESS_PATTERNS, {
|
|
1134
1153
|
id: "CODE-006",
|
|
@@ -1136,6 +1155,7 @@ var codeSafetyChecks = {
|
|
|
1136
1155
|
title: "Environment variable access",
|
|
1137
1156
|
loc,
|
|
1138
1157
|
lineNum,
|
|
1158
|
+
source,
|
|
1139
1159
|
codeBlockCtx: cbCtx
|
|
1140
1160
|
});
|
|
1141
1161
|
checkPatterns(results, line, DYNAMIC_CODE_PATTERNS, {
|
|
@@ -1143,7 +1163,8 @@ var codeSafetyChecks = {
|
|
|
1143
1163
|
severity: "HIGH",
|
|
1144
1164
|
title: "Dynamic code generation pattern",
|
|
1145
1165
|
loc,
|
|
1146
|
-
lineNum
|
|
1166
|
+
lineNum,
|
|
1167
|
+
source
|
|
1147
1168
|
});
|
|
1148
1169
|
{
|
|
1149
1170
|
const isDoc = isInDocumentationContext(lines, i);
|
|
@@ -1153,7 +1174,8 @@ var codeSafetyChecks = {
|
|
|
1153
1174
|
severity: "HIGH",
|
|
1154
1175
|
title: "Permission escalation",
|
|
1155
1176
|
loc,
|
|
1156
|
-
lineNum
|
|
1177
|
+
lineNum,
|
|
1178
|
+
source
|
|
1157
1179
|
});
|
|
1158
1180
|
}
|
|
1159
1181
|
}
|
|
@@ -1184,6 +1206,7 @@ function checkPatterns(results, line, patterns, opts) {
|
|
|
1184
1206
|
message: `At ${opts.loc}: ${line.trim().slice(0, 120)}${msgSuffix}`,
|
|
1185
1207
|
line: opts.lineNum,
|
|
1186
1208
|
snippet: line.trim().slice(0, 120),
|
|
1209
|
+
source: opts.source,
|
|
1187
1210
|
reducedFrom
|
|
1188
1211
|
});
|
|
1189
1212
|
return;
|
|
@@ -1215,7 +1238,8 @@ function scanEncodedStrings(results, text, source) {
|
|
|
1215
1238
|
title: "Long encoded string",
|
|
1216
1239
|
message: `${source}:${lineNum}: Found ${str.length}-char encoded string.`,
|
|
1217
1240
|
line: lineNum,
|
|
1218
|
-
snippet: str.slice(0, 80) + "..."
|
|
1241
|
+
snippet: str.slice(0, 80) + "...",
|
|
1242
|
+
source
|
|
1219
1243
|
});
|
|
1220
1244
|
}
|
|
1221
1245
|
}
|
|
@@ -1230,7 +1254,8 @@ function scanEncodedStrings(results, text, source) {
|
|
|
1230
1254
|
severity: "MEDIUM",
|
|
1231
1255
|
title: "High entropy string",
|
|
1232
1256
|
message: `${source}:${lineNum}: String "${match[0].slice(0, 30)}..." has entropy ${entropy.toFixed(2)} bits/char.`,
|
|
1233
|
-
line: lineNum
|
|
1257
|
+
line: lineNum,
|
|
1258
|
+
source
|
|
1234
1259
|
});
|
|
1235
1260
|
}
|
|
1236
1261
|
}
|
|
@@ -1247,7 +1272,8 @@ function scanEncodedStrings(results, text, source) {
|
|
|
1247
1272
|
category: "CODE",
|
|
1248
1273
|
severity: "CRITICAL",
|
|
1249
1274
|
title: "Multi-layer encoding detected",
|
|
1250
|
-
message: `${source}: Contains nested encoding/decoding operations
|
|
1275
|
+
message: `${source}: Contains nested encoding/decoding operations.`,
|
|
1276
|
+
source
|
|
1251
1277
|
});
|
|
1252
1278
|
break;
|
|
1253
1279
|
}
|
|
@@ -1262,7 +1288,8 @@ function scanObfuscation(results, text, source) {
|
|
|
1262
1288
|
category: "CODE",
|
|
1263
1289
|
severity: "MEDIUM",
|
|
1264
1290
|
title: "Obfuscated variable names",
|
|
1265
|
-
message: `${source}: Found ${obfMatches.length} hex-style variable names (e.g. ${obfMatches[0]}). May indicate obfuscated code
|
|
1291
|
+
message: `${source}: Found ${obfMatches.length} hex-style variable names (e.g. ${obfMatches[0]}). May indicate obfuscated code.`,
|
|
1292
|
+
source
|
|
1266
1293
|
});
|
|
1267
1294
|
}
|
|
1268
1295
|
}
|
|
@@ -1453,6 +1480,7 @@ var supplyChainChecks = {
|
|
|
1453
1480
|
message: `${source}:${lineNum}: References an MCP server. Verify it is from a trusted source.${msgSuffix}`,
|
|
1454
1481
|
line: lineNum,
|
|
1455
1482
|
snippet: line.trim().slice(0, 120),
|
|
1483
|
+
source,
|
|
1456
1484
|
reducedFrom
|
|
1457
1485
|
});
|
|
1458
1486
|
}
|
|
@@ -1464,7 +1492,8 @@ var supplyChainChecks = {
|
|
|
1464
1492
|
title: "npx -y auto-install",
|
|
1465
1493
|
message: `${source}:${lineNum}: Uses npx -y which auto-installs packages without confirmation.`,
|
|
1466
1494
|
line: lineNum,
|
|
1467
|
-
snippet: line.trim().slice(0, 120)
|
|
1495
|
+
snippet: line.trim().slice(0, 120),
|
|
1496
|
+
source
|
|
1468
1497
|
});
|
|
1469
1498
|
}
|
|
1470
1499
|
if (NPM_INSTALL_PATTERN.test(line) || PIP_INSTALL_PATTERN.test(line)) {
|
|
@@ -1494,6 +1523,7 @@ var supplyChainChecks = {
|
|
|
1494
1523
|
message: `${source}:${lineNum}: Installs packages. Verify package names are legitimate.${msgSuffix}`,
|
|
1495
1524
|
line: lineNum,
|
|
1496
1525
|
snippet: line.trim().slice(0, 120),
|
|
1526
|
+
source,
|
|
1497
1527
|
reducedFrom
|
|
1498
1528
|
});
|
|
1499
1529
|
}
|
|
@@ -1506,7 +1536,8 @@ var supplyChainChecks = {
|
|
|
1506
1536
|
title: "git clone command",
|
|
1507
1537
|
message: `${source}:${lineNum}: Clones a git repository. Verify the source.`,
|
|
1508
1538
|
line: lineNum,
|
|
1509
|
-
snippet: line.trim().slice(0, 120)
|
|
1539
|
+
snippet: line.trim().slice(0, 120),
|
|
1540
|
+
source
|
|
1510
1541
|
});
|
|
1511
1542
|
}
|
|
1512
1543
|
const urls = line.match(URL_PATTERN) || [];
|
|
@@ -1535,6 +1566,7 @@ var supplyChainChecks = {
|
|
|
1535
1566
|
message: `${source}:${lineNum}: Uses insecure HTTP: ${url}${msgSuffix}`,
|
|
1536
1567
|
line: lineNum,
|
|
1537
1568
|
snippet: url,
|
|
1569
|
+
source,
|
|
1538
1570
|
reducedFrom
|
|
1539
1571
|
});
|
|
1540
1572
|
}
|
|
@@ -1548,7 +1580,8 @@ var supplyChainChecks = {
|
|
|
1548
1580
|
title: "IP address used instead of domain",
|
|
1549
1581
|
message: `${source}:${lineNum}: Uses raw IP address: ${url}. This may bypass DNS-based security.`,
|
|
1550
1582
|
line: lineNum,
|
|
1551
|
-
snippet: url
|
|
1583
|
+
snippet: url,
|
|
1584
|
+
source
|
|
1552
1585
|
});
|
|
1553
1586
|
}
|
|
1554
1587
|
}
|
|
@@ -1562,7 +1595,8 @@ var supplyChainChecks = {
|
|
|
1562
1595
|
title: "Suspicious domain detected",
|
|
1563
1596
|
message: `${source}:${lineNum}: References suspicious domain "${domain}".`,
|
|
1564
1597
|
line: lineNum,
|
|
1565
|
-
snippet: url
|
|
1598
|
+
snippet: url,
|
|
1599
|
+
source
|
|
1566
1600
|
});
|
|
1567
1601
|
break;
|
|
1568
1602
|
}
|
|
@@ -1762,7 +1796,7 @@ var resourceChecks = {
|
|
|
1762
1796
|
|
|
1763
1797
|
// src/ioc/matcher.ts
|
|
1764
1798
|
import { createHash } from "crypto";
|
|
1765
|
-
import { openSync, readSync, closeSync, statSync
|
|
1799
|
+
import { openSync as openSync2, readSync as readSync2, closeSync as closeSync2, statSync } from "fs";
|
|
1766
1800
|
import { join as join3 } from "path";
|
|
1767
1801
|
|
|
1768
1802
|
// src/utils/levenshtein.ts
|
|
@@ -1811,18 +1845,18 @@ function isPrivateIP(ip) {
|
|
|
1811
1845
|
var HASH_CHUNK_SIZE = 64 * 1024;
|
|
1812
1846
|
function computeFileHash(filePath) {
|
|
1813
1847
|
const hash = createHash("sha256");
|
|
1814
|
-
const fd =
|
|
1848
|
+
const fd = openSync2(filePath, "r");
|
|
1815
1849
|
try {
|
|
1816
1850
|
const buf = Buffer.alloc(HASH_CHUNK_SIZE);
|
|
1817
1851
|
let bytesRead;
|
|
1818
1852
|
do {
|
|
1819
|
-
bytesRead =
|
|
1853
|
+
bytesRead = readSync2(fd, buf, 0, HASH_CHUNK_SIZE, null);
|
|
1820
1854
|
if (bytesRead > 0) {
|
|
1821
1855
|
hash.update(buf.subarray(0, bytesRead));
|
|
1822
1856
|
}
|
|
1823
1857
|
} while (bytesRead > 0);
|
|
1824
1858
|
} finally {
|
|
1825
|
-
|
|
1859
|
+
closeSync2(fd);
|
|
1826
1860
|
}
|
|
1827
1861
|
return hash.digest("hex");
|
|
1828
1862
|
}
|
|
@@ -1833,7 +1867,7 @@ function matchMaliciousHashes(skill, ioc) {
|
|
|
1833
1867
|
for (const file of skill.files) {
|
|
1834
1868
|
const filePath = join3(skill.dirPath, file.path);
|
|
1835
1869
|
try {
|
|
1836
|
-
const stat =
|
|
1870
|
+
const stat = statSync(filePath);
|
|
1837
1871
|
if (stat.size === 0) continue;
|
|
1838
1872
|
const hash = computeFileHash(filePath);
|
|
1839
1873
|
if (hash === EMPTY_FILE_HASH) continue;
|
|
@@ -1924,7 +1958,8 @@ var iocChecks = {
|
|
|
1924
1958
|
severity: "CRITICAL",
|
|
1925
1959
|
title: "Known malicious file hash",
|
|
1926
1960
|
message: `File "${match.file}" matches known malicious hash: ${match.description}`,
|
|
1927
|
-
snippet: match.hash
|
|
1961
|
+
snippet: match.hash,
|
|
1962
|
+
source: match.file
|
|
1928
1963
|
});
|
|
1929
1964
|
}
|
|
1930
1965
|
const ipMatches = matchC2IPs(skill, ioc);
|
|
@@ -1936,7 +1971,8 @@ var iocChecks = {
|
|
|
1936
1971
|
title: "Known C2 IP address",
|
|
1937
1972
|
message: `${match.source}:${match.line}: Contains known C2 server IP: ${match.ip}`,
|
|
1938
1973
|
line: match.line,
|
|
1939
|
-
snippet: match.snippet
|
|
1974
|
+
snippet: match.snippet,
|
|
1975
|
+
source: match.source
|
|
1940
1976
|
});
|
|
1941
1977
|
}
|
|
1942
1978
|
const skillName = skill.frontmatter.name;
|
|
@@ -2029,8 +2065,8 @@ function deduplicateResults(results) {
|
|
|
2029
2065
|
LOW: 1
|
|
2030
2066
|
};
|
|
2031
2067
|
for (const r of results) {
|
|
2032
|
-
const
|
|
2033
|
-
const key = `${r.id}::${
|
|
2068
|
+
const sourceKey = r.source ?? `_no_source_:${r.category}:${r.line ?? ""}`;
|
|
2069
|
+
const key = `${r.id}::${sourceKey}`;
|
|
2034
2070
|
const group = groups.get(key);
|
|
2035
2071
|
if (group) {
|
|
2036
2072
|
group.push(r);
|
|
@@ -2044,16 +2080,13 @@ function deduplicateResults(results) {
|
|
|
2044
2080
|
const best = { ...group[0] };
|
|
2045
2081
|
if (group.length > 1) {
|
|
2046
2082
|
best.occurrences = group.length;
|
|
2047
|
-
best.
|
|
2083
|
+
const suffix = best.source ? ` (${group.length} occurrences in this file)` : ` (${group.length} occurrences)`;
|
|
2084
|
+
best.message += suffix;
|
|
2048
2085
|
}
|
|
2049
2086
|
deduped.push(best);
|
|
2050
2087
|
}
|
|
2051
2088
|
return deduped;
|
|
2052
2089
|
}
|
|
2053
|
-
function extractSourceFile(message) {
|
|
2054
|
-
const m = message.match(/^(?:At\s+)?([^:]+):\d+:/);
|
|
2055
|
-
return m?.[1] ?? "unknown";
|
|
2056
|
-
}
|
|
2057
2090
|
function calculateScore(results) {
|
|
2058
2091
|
let score = 100;
|
|
2059
2092
|
for (const r of results) {
|
|
@@ -2328,8 +2361,13 @@ var program = new Command();
|
|
|
2328
2361
|
program.name("skill-checker").description(
|
|
2329
2362
|
"Security checker for Claude Code skills - detect injection, malicious code, and supply chain risks"
|
|
2330
2363
|
).version(pkg.version);
|
|
2364
|
+
var VALID_POLICIES = ["strict", "balanced", "permissive"];
|
|
2331
2365
|
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(
|
|
2332
2366
|
(path, opts) => {
|
|
2367
|
+
if (opts.policy && !VALID_POLICIES.includes(opts.policy)) {
|
|
2368
|
+
console.error(`Error: invalid policy "${opts.policy}". Valid values: ${VALID_POLICIES.join(", ")}`);
|
|
2369
|
+
process.exit(1);
|
|
2370
|
+
}
|
|
2333
2371
|
const config = loadConfig(path, opts.config);
|
|
2334
2372
|
if (opts.policy) {
|
|
2335
2373
|
config.policy = opts.policy;
|