clawarmor 1.2.0 → 2.0.0-alpha.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/cli.js +44 -2
- package/lib/audit-log.js +38 -0
- package/lib/audit.js +37 -2
- package/lib/checks/credential-files.js +156 -0
- package/lib/checks/exec-approval.js +64 -0
- package/lib/checks/git-credential-leak.js +135 -0
- package/lib/checks/skill-pinning.js +159 -0
- package/lib/checks/token-age.js +180 -0
- package/lib/log-viewer.js +140 -0
- package/lib/prescan.js +167 -0
- package/lib/protect.js +333 -0
- package/lib/scan.js +14 -0
- package/lib/watch.js +235 -0
- package/package.json +1 -1
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// T-PERSIST-002 — Skill Version Pinning
|
|
2
|
+
// Checks that installed skills have explicit version pins to prevent
|
|
3
|
+
// update poisoning attacks.
|
|
4
|
+
|
|
5
|
+
import { existsSync, readdirSync, readFileSync } from 'fs';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
import { homedir } from 'os';
|
|
8
|
+
import { get } from '../config.js';
|
|
9
|
+
|
|
10
|
+
const HOME = homedir();
|
|
11
|
+
const OC_DIR = join(HOME, '.openclaw');
|
|
12
|
+
const SKILLS_DIR = join(OC_DIR, 'skills');
|
|
13
|
+
|
|
14
|
+
// A version pin looks like @1.2.3 or @1.2.3-beta.1 (semver)
|
|
15
|
+
const SEMVER_PIN = /^[\^~]?\d+\.\d+\.\d+/;
|
|
16
|
+
// "latest", "next", "*", ranges like ">=1.0.0" are NOT pins
|
|
17
|
+
const FLOATING = /^(latest|next|beta|alpha|\*|>=|>|<|~\d|^\d)/;
|
|
18
|
+
|
|
19
|
+
function isPinned(version) {
|
|
20
|
+
if (!version || typeof version !== 'string') return false;
|
|
21
|
+
if (FLOATING.test(version)) return false;
|
|
22
|
+
return SEMVER_PIN.test(version);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function collectUnpinnedFromConfig(config) {
|
|
26
|
+
const unpinned = [];
|
|
27
|
+
|
|
28
|
+
// Check skills.managed (array or object of { name, version })
|
|
29
|
+
const managed = get(config, 'skills.managed', null);
|
|
30
|
+
if (Array.isArray(managed)) {
|
|
31
|
+
for (const entry of managed) {
|
|
32
|
+
if (typeof entry === 'string') {
|
|
33
|
+
// "skill-name" with no version
|
|
34
|
+
unpinned.push({ name: entry, source: 'skills.managed', version: null });
|
|
35
|
+
} else if (entry && typeof entry === 'object') {
|
|
36
|
+
const name = entry.name || entry.id || JSON.stringify(entry);
|
|
37
|
+
if (!isPinned(entry.version)) {
|
|
38
|
+
unpinned.push({ name, source: 'skills.managed', version: entry.version || null });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
} else if (managed && typeof managed === 'object') {
|
|
43
|
+
for (const [name, value] of Object.entries(managed)) {
|
|
44
|
+
const version = typeof value === 'string' ? value : value?.version;
|
|
45
|
+
if (!isPinned(version)) {
|
|
46
|
+
unpinned.push({ name, source: 'skills.managed', version: version || null });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Check skills.installed
|
|
52
|
+
const installed = get(config, 'skills.installed', null);
|
|
53
|
+
if (Array.isArray(installed)) {
|
|
54
|
+
for (const entry of installed) {
|
|
55
|
+
if (typeof entry === 'string') {
|
|
56
|
+
// May be "name@version" or just "name"
|
|
57
|
+
const atIdx = entry.lastIndexOf('@');
|
|
58
|
+
if (atIdx > 0) {
|
|
59
|
+
const version = entry.slice(atIdx + 1);
|
|
60
|
+
const name = entry.slice(0, atIdx);
|
|
61
|
+
if (!isPinned(version)) unpinned.push({ name, source: 'skills.installed', version });
|
|
62
|
+
} else {
|
|
63
|
+
unpinned.push({ name: entry, source: 'skills.installed', version: null });
|
|
64
|
+
}
|
|
65
|
+
} else if (entry && typeof entry === 'object') {
|
|
66
|
+
const name = entry.name || entry.id || JSON.stringify(entry);
|
|
67
|
+
if (!isPinned(entry.version)) {
|
|
68
|
+
unpinned.push({ name, source: 'skills.installed', version: entry.version || null });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return unpinned;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function collectUnpinnedFromDisk() {
|
|
78
|
+
const unpinned = [];
|
|
79
|
+
if (!existsSync(SKILLS_DIR)) return unpinned;
|
|
80
|
+
|
|
81
|
+
let entries;
|
|
82
|
+
try { entries = readdirSync(SKILLS_DIR, { withFileTypes: true }); }
|
|
83
|
+
catch { return unpinned; }
|
|
84
|
+
|
|
85
|
+
for (const entry of entries) {
|
|
86
|
+
if (!entry.isDirectory()) continue;
|
|
87
|
+
const skillDir = join(SKILLS_DIR, entry.name);
|
|
88
|
+
|
|
89
|
+
// Look for a package.json to find version
|
|
90
|
+
const pkgPath = join(skillDir, 'package.json');
|
|
91
|
+
if (existsSync(pkgPath)) {
|
|
92
|
+
try {
|
|
93
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
94
|
+
// If installed from a range or "latest", flag it
|
|
95
|
+
// We check _resolved or _requested for npm install metadata
|
|
96
|
+
const requested = pkg._requested?.rawSpec || pkg._spec;
|
|
97
|
+
if (requested && !isPinned(requested)) {
|
|
98
|
+
unpinned.push({ name: entry.name, source: 'skills/', version: requested });
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
// If no version at all in package.json
|
|
102
|
+
if (!pkg.version) {
|
|
103
|
+
unpinned.push({ name: entry.name, source: 'skills/', version: null });
|
|
104
|
+
}
|
|
105
|
+
// If version looks like a range (shouldn't happen for installed, but defensive)
|
|
106
|
+
} catch { /* skip */ }
|
|
107
|
+
} else {
|
|
108
|
+
// Directory exists but no package.json — unpinned / manual install
|
|
109
|
+
const skillMd = join(skillDir, 'SKILL.md');
|
|
110
|
+
if (existsSync(skillMd)) {
|
|
111
|
+
// Check frontmatter for version
|
|
112
|
+
try {
|
|
113
|
+
const md = readFileSync(skillMd, 'utf8');
|
|
114
|
+
const versionMatch = md.match(/^version:\s*(.+)$/m);
|
|
115
|
+
const version = versionMatch ? versionMatch[1].trim() : null;
|
|
116
|
+
if (!isPinned(version)) {
|
|
117
|
+
unpinned.push({ name: entry.name, source: 'skills/', version });
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
unpinned.push({ name: entry.name, source: 'skills/', version: null });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return unpinned;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function checkSkillPinning(config) {
|
|
130
|
+
const fromConfig = collectUnpinnedFromConfig(config);
|
|
131
|
+
const fromDisk = collectUnpinnedFromDisk();
|
|
132
|
+
|
|
133
|
+
// Merge, dedup by name
|
|
134
|
+
const seen = new Set(fromConfig.map(s => s.name));
|
|
135
|
+
const all = [...fromConfig];
|
|
136
|
+
for (const s of fromDisk) {
|
|
137
|
+
if (!seen.has(s.name)) { all.push(s); seen.add(s.name); }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (!all.length) {
|
|
141
|
+
return { id: 'persist.skill_pinning', severity: 'MEDIUM', passed: true,
|
|
142
|
+
passedMsg: 'All installed skills have explicit version pins' };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const list = all.map(({ name, version }) =>
|
|
146
|
+
`• ${name}${version ? ` (version: "${version}" — not pinned)` : ' (no version specified)'}`
|
|
147
|
+
).join('\n');
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
id: 'persist.skill_pinning',
|
|
151
|
+
severity: 'MEDIUM',
|
|
152
|
+
passed: false,
|
|
153
|
+
title: `${all.length} skill${all.length > 1 ? 's' : ''} installed without a version pin`,
|
|
154
|
+
description: `Skills without a pinned version can silently update to a malicious release.\nAttack (T-PERSIST-002): attacker publishes a new "latest" version of a skill\nyou use — your next gateway start runs the malicious code automatically.\n\n${list}`,
|
|
155
|
+
fix: `Pin skills to a specific version when installing:\n openclaw clawhub install <skill>@<version>\n\nExample:\n openclaw clawhub install weather@1.4.2`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export default [checkSkillPinning];
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// T-ACCESS-003 — Token Age Hygiene
|
|
2
|
+
// Checks agent-accounts.json for stale credentials (by date fields only).
|
|
3
|
+
// NEVER logs or prints actual credential values.
|
|
4
|
+
|
|
5
|
+
import { readFileSync, existsSync } from 'fs';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
import { homedir } from 'os';
|
|
8
|
+
|
|
9
|
+
const HOME = homedir();
|
|
10
|
+
const ACCOUNTS_FILE = join(HOME, '.openclaw', 'agent-accounts.json');
|
|
11
|
+
|
|
12
|
+
const CRED_KEY_PATTERN = /token|key|secret|password|credential/i;
|
|
13
|
+
const DATE_KEY_PATTERN = /creat|updat|generat|issu|born|added|refresh|rotat|expir|since|timestamp|date|time/i;
|
|
14
|
+
|
|
15
|
+
const WARN_DAYS = 90;
|
|
16
|
+
const HIGH_DAYS = 180;
|
|
17
|
+
const MS_PER_DAY = 86400000;
|
|
18
|
+
|
|
19
|
+
function parseDate(value) {
|
|
20
|
+
if (value == null) return null;
|
|
21
|
+
// Unix timestamp (seconds) — must be between 2015 and 2040
|
|
22
|
+
if (typeof value === 'number' && value > 1420000000 && value < 2208988800) {
|
|
23
|
+
return new Date(value * 1000);
|
|
24
|
+
}
|
|
25
|
+
if (typeof value === 'string' && value.length >= 8) {
|
|
26
|
+
const d = new Date(value);
|
|
27
|
+
if (!isNaN(d.getTime()) && d.getFullYear() >= 2015 && d.getFullYear() <= 2040) {
|
|
28
|
+
return d;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function ageInDays(date) {
|
|
35
|
+
return Math.floor((Date.now() - date.getTime()) / MS_PER_DAY);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Walk an object and collect { keyPath, date } for any date-like values
|
|
39
|
+
// that appear alongside credential-like keys.
|
|
40
|
+
function collectDateFields(obj, parentPath = '') {
|
|
41
|
+
const results = [];
|
|
42
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return results;
|
|
43
|
+
|
|
44
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
45
|
+
const keyPath = parentPath ? `${parentPath}.${key}` : key;
|
|
46
|
+
|
|
47
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
48
|
+
results.push(...collectDateFields(value, keyPath));
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (Array.isArray(value)) {
|
|
53
|
+
value.forEach((item, i) => {
|
|
54
|
+
if (typeof item === 'object' && item !== null) {
|
|
55
|
+
results.push(...collectDateFields(item, `${keyPath}[${i}]`));
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Only consider date-like values attached to date-named keys
|
|
62
|
+
// that appear in an object that also has credential-like keys
|
|
63
|
+
if (DATE_KEY_PATTERN.test(key)) {
|
|
64
|
+
const date = parseDate(value);
|
|
65
|
+
if (date) {
|
|
66
|
+
results.push({ keyPath, date });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return results;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Find objects in the tree that contain at least one credential-key
|
|
75
|
+
// and return any date fields within those objects.
|
|
76
|
+
function findCredentialDates(obj, parentPath = '') {
|
|
77
|
+
const results = [];
|
|
78
|
+
if (!obj || typeof obj !== 'object') return results;
|
|
79
|
+
|
|
80
|
+
if (Array.isArray(obj)) {
|
|
81
|
+
obj.forEach((item, i) => {
|
|
82
|
+
results.push(...findCredentialDates(item, `${parentPath}[${i}]`));
|
|
83
|
+
});
|
|
84
|
+
return results;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const keys = Object.keys(obj);
|
|
88
|
+
const hasCredKey = keys.some(k => CRED_KEY_PATTERN.test(k));
|
|
89
|
+
|
|
90
|
+
if (hasCredKey) {
|
|
91
|
+
// This object has credential-like keys; look for date fields within it
|
|
92
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
93
|
+
const keyPath = parentPath ? `${parentPath}.${key}` : key;
|
|
94
|
+
if (DATE_KEY_PATTERN.test(key)) {
|
|
95
|
+
const date = parseDate(value);
|
|
96
|
+
if (date) results.push({ keyPath, date });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Always recurse into child objects
|
|
102
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
103
|
+
const keyPath = parentPath ? `${parentPath}.${key}` : key;
|
|
104
|
+
if (value && typeof value === 'object') {
|
|
105
|
+
results.push(...findCredentialDates(value, keyPath));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return results;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function checkTokenAge() {
|
|
113
|
+
if (!existsSync(ACCOUNTS_FILE)) {
|
|
114
|
+
return { id: 'access.token_age', severity: 'INFO', passed: true,
|
|
115
|
+
passedMsg: 'agent-accounts.json not found — token age check skipped' };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let accounts;
|
|
119
|
+
try {
|
|
120
|
+
accounts = JSON.parse(readFileSync(ACCOUNTS_FILE, 'utf8'));
|
|
121
|
+
} catch {
|
|
122
|
+
return { id: 'access.token_age', severity: 'INFO', passed: true,
|
|
123
|
+
passedMsg: 'agent-accounts.json could not be parsed — token age check skipped' };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const dateFields = findCredentialDates(accounts);
|
|
127
|
+
if (!dateFields.length) {
|
|
128
|
+
return { id: 'access.token_age', severity: 'INFO', passed: true,
|
|
129
|
+
passedMsg: 'No date fields found in agent-accounts.json — token age check skipped' };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Deduplicate by keyPath, keep earliest date
|
|
133
|
+
const byPath = new Map();
|
|
134
|
+
for (const { keyPath, date } of dateFields) {
|
|
135
|
+
if (!byPath.has(keyPath) || date < byPath.get(keyPath)) {
|
|
136
|
+
byPath.set(keyPath, date);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const critical180 = [];
|
|
141
|
+
const warn90 = [];
|
|
142
|
+
|
|
143
|
+
for (const [keyPath, date] of byPath) {
|
|
144
|
+
const days = ageInDays(date);
|
|
145
|
+
if (days >= HIGH_DAYS) {
|
|
146
|
+
critical180.push({ keyPath, days });
|
|
147
|
+
} else if (days >= WARN_DAYS) {
|
|
148
|
+
warn90.push({ keyPath, days });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (critical180.length) {
|
|
153
|
+
const list = critical180.map(({ keyPath, days }) => `• ${keyPath} (${days} days old)`).join('\n');
|
|
154
|
+
return {
|
|
155
|
+
id: 'access.token_age',
|
|
156
|
+
severity: 'HIGH',
|
|
157
|
+
passed: false,
|
|
158
|
+
title: `Credentials older than ${HIGH_DAYS} days detected`,
|
|
159
|
+
description: `The following credential date fields indicate tokens/keys that have not been\nrotated in over ${HIGH_DAYS} days. Stale credentials increase exposure window if leaked.\n\n${list}`,
|
|
160
|
+
fix: `Rotate these credentials at their respective service dashboards.\nThen update agent-accounts.json with new values and fresh dates.`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (warn90.length) {
|
|
165
|
+
const list = warn90.map(({ keyPath, days }) => `• ${keyPath} (${days} days old)`).join('\n');
|
|
166
|
+
return {
|
|
167
|
+
id: 'access.token_age',
|
|
168
|
+
severity: 'MEDIUM',
|
|
169
|
+
passed: false,
|
|
170
|
+
title: `Credentials older than ${WARN_DAYS} days detected`,
|
|
171
|
+
description: `The following credential date fields indicate tokens/keys not rotated in\nover ${WARN_DAYS} days. Consider rotating them proactively.\n\n${list}`,
|
|
172
|
+
fix: `Rotate these credentials at their respective service dashboards.\nThen update agent-accounts.json with new values and fresh dates.`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return { id: 'access.token_age', severity: 'INFO', passed: true,
|
|
177
|
+
passedMsg: `All credential date fields are within ${WARN_DAYS} days` };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export default [checkTokenAge];
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// ClawArmor v2.0 — Audit Log Viewer
|
|
2
|
+
// Reads ~/.clawarmor/audit.log (JSONL) and displays events in human-readable form.
|
|
3
|
+
// Flags: --since <Nd|Nh> --json --tokens
|
|
4
|
+
|
|
5
|
+
import { existsSync, readFileSync } from 'fs';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
import { homedir } from 'os';
|
|
8
|
+
import { paint, severityColor } from './output/colors.js';
|
|
9
|
+
|
|
10
|
+
const LOG_FILE = join(homedir(), '.clawarmor', 'audit.log');
|
|
11
|
+
const SEP = paint.dim('─'.repeat(52));
|
|
12
|
+
|
|
13
|
+
function parseEntries() {
|
|
14
|
+
if (!existsSync(LOG_FILE)) return null;
|
|
15
|
+
const raw = readFileSync(LOG_FILE, 'utf8');
|
|
16
|
+
return raw
|
|
17
|
+
.split('\n')
|
|
18
|
+
.filter(Boolean)
|
|
19
|
+
.map(line => { try { return JSON.parse(line); } catch { return null; } })
|
|
20
|
+
.filter(Boolean);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseSince(sinceArg) {
|
|
24
|
+
if (!sinceArg) return null;
|
|
25
|
+
const m = sinceArg.match(/^(\d+)(d|h)$/);
|
|
26
|
+
if (!m) return null;
|
|
27
|
+
const n = parseInt(m[1], 10);
|
|
28
|
+
const ms = m[2] === 'd' ? n * 86_400_000 : n * 3_600_000;
|
|
29
|
+
return new Date(Date.now() - ms);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function cmdColor(cmd) {
|
|
33
|
+
switch (cmd) {
|
|
34
|
+
case 'audit': return paint.cyan(cmd.padEnd(7));
|
|
35
|
+
case 'scan': return paint.cyan(cmd.padEnd(7));
|
|
36
|
+
case 'prescan': return paint.cyan(cmd.padEnd(7));
|
|
37
|
+
case 'watch': return paint.dim(cmd.padEnd(7));
|
|
38
|
+
default: return paint.dim((cmd || '?').padEnd(7));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function formatScore(score, delta) {
|
|
43
|
+
if (score == null) return '';
|
|
44
|
+
const s = `${score}/100`;
|
|
45
|
+
if (delta == null) return paint.bold(s);
|
|
46
|
+
const dStr = delta >= 0 ? `+${delta}` : `${delta}`;
|
|
47
|
+
const dColor = delta >= 0 ? paint.green : paint.red;
|
|
48
|
+
return `${paint.bold(s)} ${dColor(dStr)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function formatFindings(findings) {
|
|
52
|
+
if (!Array.isArray(findings) || !findings.length) return paint.green('clean');
|
|
53
|
+
const bySev = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0, INFO: 0 };
|
|
54
|
+
for (const f of findings) bySev[f.severity] = (bySev[f.severity] || 0) + 1;
|
|
55
|
+
const parts = [];
|
|
56
|
+
if (bySev.CRITICAL) parts.push(paint.red(`${bySev.CRITICAL}C`));
|
|
57
|
+
if (bySev.HIGH) parts.push(paint.yellow(`${bySev.HIGH}H`));
|
|
58
|
+
if (bySev.MEDIUM) parts.push(paint.cyan(`${bySev.MEDIUM}M`));
|
|
59
|
+
if (bySev.LOW || bySev.INFO)
|
|
60
|
+
parts.push(paint.dim(`${(bySev.LOW || 0) + (bySev.INFO || 0)}L`));
|
|
61
|
+
return parts.join(' ') || paint.green('clean');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function formatEntry(e) {
|
|
65
|
+
const ts = new Date(e.ts).toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' });
|
|
66
|
+
const cmd = cmdColor(e.cmd);
|
|
67
|
+
const trigger = paint.dim(`[${e.trigger || 'manual'}]`);
|
|
68
|
+
const scoreStr = formatScore(e.score, e.delta);
|
|
69
|
+
const findingsStr = formatFindings(e.findings);
|
|
70
|
+
const blocked = e.blocked === true ? ` ${paint.red('BLOCKED')}` : '';
|
|
71
|
+
const skill = e.skill ? ` ${paint.cyan(e.skill)}` : '';
|
|
72
|
+
|
|
73
|
+
const parts = [paint.dim(ts), cmd, trigger];
|
|
74
|
+
if (scoreStr) parts.push(scoreStr);
|
|
75
|
+
parts.push(findingsStr);
|
|
76
|
+
|
|
77
|
+
return ` ${parts.join(' ')}${skill}${blocked}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function runLog(flags = {}) {
|
|
81
|
+
const entries = parseEntries();
|
|
82
|
+
|
|
83
|
+
if (!entries) {
|
|
84
|
+
console.log('');
|
|
85
|
+
console.log(` No audit log yet. Run ${paint.cyan('clawarmor audit')} to start.`);
|
|
86
|
+
console.log('');
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let filtered = entries;
|
|
91
|
+
|
|
92
|
+
// --since filter
|
|
93
|
+
const since = parseSince(flags.since);
|
|
94
|
+
if (since) {
|
|
95
|
+
filtered = filtered.filter(e => new Date(e.ts) >= since);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --tokens filter
|
|
99
|
+
if (flags.tokens) {
|
|
100
|
+
filtered = filtered.filter(e =>
|
|
101
|
+
Array.isArray(e.findings) &&
|
|
102
|
+
e.findings.some(f => (f.id || '').includes('token') || (f.id || '').includes('access'))
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// --json: raw JSONL output
|
|
107
|
+
if (flags.json) {
|
|
108
|
+
for (const e of filtered) console.log(JSON.stringify(e));
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Default: last 10 events
|
|
113
|
+
const recent = filtered.slice(-10);
|
|
114
|
+
|
|
115
|
+
console.log('');
|
|
116
|
+
if (!recent.length) {
|
|
117
|
+
console.log(` No log entries match the given filters.`);
|
|
118
|
+
console.log(` ${paint.dim('Total entries in log:')} ${entries.length}`);
|
|
119
|
+
console.log('');
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
console.log(SEP);
|
|
124
|
+
const label = flags.since ? ` — since ${flags.since}` : ` — last ${recent.length}`;
|
|
125
|
+
console.log(` ${paint.bold('ClawArmor Audit Log')}${paint.dim(label)}`);
|
|
126
|
+
console.log(SEP);
|
|
127
|
+
console.log('');
|
|
128
|
+
|
|
129
|
+
for (const e of recent) {
|
|
130
|
+
console.log(formatEntry(e));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (filtered.length > 10) {
|
|
134
|
+
console.log('');
|
|
135
|
+
console.log(` ${paint.dim(`(showing 10 of ${filtered.length} entries — use --since to filter)`)}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
console.log('');
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
package/lib/prescan.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// ClawArmor v2.0 — Pre-scan a skill before installing
|
|
2
|
+
// Downloads the npm package to a temp dir, scans it with the full
|
|
3
|
+
// ClawArmor scanner, and exits 1 (blocks install) only on CRITICAL findings.
|
|
4
|
+
|
|
5
|
+
import { mkdirSync, rmSync, readdirSync } from 'fs';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
import { tmpdir } from 'os';
|
|
8
|
+
import { execSync } from 'child_process';
|
|
9
|
+
import { scanFile } from './scanner/file-scanner.js';
|
|
10
|
+
import { scanSkillMdFiles } from './scanner/skill-md-scanner.js';
|
|
11
|
+
import { paint, severityColor } from './output/colors.js';
|
|
12
|
+
import { append } from './audit-log.js';
|
|
13
|
+
|
|
14
|
+
const SEP = paint.dim('─'.repeat(52));
|
|
15
|
+
|
|
16
|
+
function getAllFiles(dir, files = []) {
|
|
17
|
+
try {
|
|
18
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
19
|
+
if (e.name.startsWith('.') || e.name === 'node_modules' || e.name === '__pycache__') continue;
|
|
20
|
+
const fp = join(dir, e.name);
|
|
21
|
+
if (e.isDirectory()) getAllFiles(fp, files);
|
|
22
|
+
else files.push(fp);
|
|
23
|
+
}
|
|
24
|
+
} catch { /* permission denied */ }
|
|
25
|
+
return files;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function cleanupTmp(dir) {
|
|
29
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* non-fatal */ }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function runPrescan(skillName) {
|
|
33
|
+
console.log('');
|
|
34
|
+
console.log(` ${paint.bold('ClawArmor Prescan')} — ${paint.cyan(skillName)}`);
|
|
35
|
+
console.log(` ${paint.dim('Fetching package from npm registry...')}`);
|
|
36
|
+
console.log('');
|
|
37
|
+
|
|
38
|
+
const tmpDir = join(tmpdir(), `clawarmor-prescan-${Date.now()}`);
|
|
39
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
40
|
+
|
|
41
|
+
// ── Step 1: Download via npm pack ─────────────────────────────────────────
|
|
42
|
+
let tarball;
|
|
43
|
+
try {
|
|
44
|
+
execSync(`npm pack ${skillName}`, {
|
|
45
|
+
cwd: tmpDir,
|
|
46
|
+
timeout: 30000,
|
|
47
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
48
|
+
});
|
|
49
|
+
const tarballs = readdirSync(tmpDir).filter(f => f.endsWith('.tgz'));
|
|
50
|
+
if (!tarballs.length) throw new Error('npm pack produced no tarball');
|
|
51
|
+
tarball = join(tmpDir, tarballs[0]);
|
|
52
|
+
} catch {
|
|
53
|
+
cleanupTmp(tmpDir);
|
|
54
|
+
console.log(` ${paint.dim('ℹ')} Could not fetch skill for scanning`);
|
|
55
|
+
console.log(` ${paint.dim('(package not found or network error — install not blocked)')}`);
|
|
56
|
+
console.log('');
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Step 2: Extract tarball ────────────────────────────────────────────────
|
|
61
|
+
const extractDir = join(tmpDir, 'extracted');
|
|
62
|
+
mkdirSync(extractDir, { recursive: true });
|
|
63
|
+
try {
|
|
64
|
+
execSync(`tar -xzf "${tarball}" -C "${extractDir}"`, {
|
|
65
|
+
timeout: 15000,
|
|
66
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
67
|
+
});
|
|
68
|
+
} catch {
|
|
69
|
+
cleanupTmp(tmpDir);
|
|
70
|
+
console.log(` ${paint.dim('ℹ')} Could not extract skill package — install not blocked`);
|
|
71
|
+
console.log('');
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Step 3: Collect all files ──────────────────────────────────────────────
|
|
76
|
+
const allFiles = getAllFiles(extractDir);
|
|
77
|
+
console.log(` ${paint.dim('Scanning')} ${allFiles.length} file${allFiles.length !== 1 ? 's' : ''}...`);
|
|
78
|
+
console.log('');
|
|
79
|
+
|
|
80
|
+
// ── Step 4: Run ClawArmor scanners ────────────────────────────────────────
|
|
81
|
+
// Not a built-in — treat as third-party (isBuiltin = false)
|
|
82
|
+
const codeFindings = allFiles.flatMap(f => scanFile(f, false));
|
|
83
|
+
const mdResults = scanSkillMdFiles(allFiles, false);
|
|
84
|
+
const mdFindings = mdResults.flatMap(r => r.findings);
|
|
85
|
+
const allFindings = [...codeFindings, ...mdFindings];
|
|
86
|
+
|
|
87
|
+
const criticals = allFindings.filter(f => f.severity === 'CRITICAL');
|
|
88
|
+
const highs = allFindings.filter(f => f.severity === 'HIGH');
|
|
89
|
+
const mediums = allFindings.filter(f => f.severity === 'MEDIUM');
|
|
90
|
+
const lows = allFindings.filter(f => f.severity === 'LOW' || f.severity === 'INFO');
|
|
91
|
+
|
|
92
|
+
// ── Cleanup (always) ──────────────────────────────────────────────────────
|
|
93
|
+
cleanupTmp(tmpDir);
|
|
94
|
+
|
|
95
|
+
// ── Audit log ─────────────────────────────────────────────────────────────
|
|
96
|
+
append({
|
|
97
|
+
cmd: 'prescan',
|
|
98
|
+
trigger: 'prescan',
|
|
99
|
+
score: null,
|
|
100
|
+
delta: null,
|
|
101
|
+
findings: allFindings.map(f => ({ id: f.patternId || f.id || '?', severity: f.severity })),
|
|
102
|
+
blocked: criticals.length > 0,
|
|
103
|
+
skill: skillName,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ── Output ────────────────────────────────────────────────────────────────
|
|
107
|
+
if (!allFindings.length) {
|
|
108
|
+
console.log(` ${paint.green('✓')} ClawArmor prescan: clean — 0 findings`);
|
|
109
|
+
console.log('');
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// CRITICAL → print details, block install (exit 1)
|
|
114
|
+
if (criticals.length) {
|
|
115
|
+
console.log(SEP);
|
|
116
|
+
console.log(` ${paint.red('✗')} ${paint.bold(`CRITICAL (${criticals.length}) — install blocked`)}`);
|
|
117
|
+
console.log(SEP);
|
|
118
|
+
for (const f of criticals) {
|
|
119
|
+
console.log('');
|
|
120
|
+
console.log(` ${paint.red('✗')} ${(severityColor['CRITICAL'] || paint.red)('[CRITICAL]')} ${paint.bold(f.title)}`);
|
|
121
|
+
console.log(` ${paint.dim(f.description || '')}`);
|
|
122
|
+
for (const m of (f.matches || []).slice(0, 2)) {
|
|
123
|
+
console.log(` ${paint.dim('→')} ${paint.cyan(':' + m.line)} ${paint.dim(m.snippet)}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (highs.length || mediums.length) {
|
|
128
|
+
console.log('');
|
|
129
|
+
const extra = [];
|
|
130
|
+
if (highs.length) extra.push(`${highs.length} HIGH`);
|
|
131
|
+
if (mediums.length) extra.push(`${mediums.length} MEDIUM`);
|
|
132
|
+
console.log(` ${paint.yellow('!')} Additional: ${extra.join(', ')} (fix criticals first)`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
console.log('');
|
|
136
|
+
console.log(` ${paint.red('✗')} Skill blocked. Do NOT install ${paint.bold(skillName)}.`);
|
|
137
|
+
console.log('');
|
|
138
|
+
return 1;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// HIGH → warn, allow install
|
|
142
|
+
if (highs.length) {
|
|
143
|
+
console.log(SEP);
|
|
144
|
+
console.log(` ${paint.yellow('⚠')} ${paint.bold(`HIGH (${highs.length}) — review before using`)}`);
|
|
145
|
+
console.log(SEP);
|
|
146
|
+
for (const f of highs) {
|
|
147
|
+
console.log('');
|
|
148
|
+
console.log(` ${paint.yellow('!')} ${(severityColor['HIGH'] || paint.yellow)('[HIGH]')} ${paint.bold(f.title)}`);
|
|
149
|
+
console.log(` ${paint.dim(f.description || '')}`);
|
|
150
|
+
for (const m of (f.matches || []).slice(0, 2)) {
|
|
151
|
+
console.log(` ${paint.dim('→')} ${paint.cyan(':' + m.line)} ${paint.dim(m.snippet)}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
console.log('');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// MEDIUM/LOW → summary line only
|
|
158
|
+
if (mediums.length || lows.length) {
|
|
159
|
+
const parts = [];
|
|
160
|
+
if (mediums.length) parts.push(`${mediums.length} medium`);
|
|
161
|
+
if (lows.length) parts.push(`${lows.length} low/info`);
|
|
162
|
+
console.log(` ${paint.dim('ℹ')} ${parts.join(', ')} additional finding${(mediums.length + lows.length) > 1 ? 's' : ''} (review manually)`);
|
|
163
|
+
console.log('');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return 0;
|
|
167
|
+
}
|