clawarmor 1.1.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 +47 -3
- package/lib/audit-log.js +38 -0
- package/lib/audit.js +55 -1
- 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/integrity.js +102 -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/scanner/file-scanner.js +7 -1
- package/lib/scanner/obfuscation.js +130 -0
- package/lib/watch.js +235 -0
- package/package.json +3 -3
|
@@ -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];
|
package/lib/integrity.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// integrity.js — Config integrity hashing (P2-3)
|
|
2
|
+
// On first clean audit: hashes the config and saves the baseline.
|
|
3
|
+
// On subsequent runs: detects changes and surfaces them.
|
|
4
|
+
// Zero external deps — uses Node.js built-in crypto.
|
|
5
|
+
|
|
6
|
+
import { createHash } from 'crypto';
|
|
7
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
import { homedir } from 'os';
|
|
10
|
+
|
|
11
|
+
const INTEGRITY_FILE = join(homedir(), '.clawarmor', 'integrity.json');
|
|
12
|
+
|
|
13
|
+
function hashFile(filePath) {
|
|
14
|
+
try {
|
|
15
|
+
const content = readFileSync(filePath, 'utf8');
|
|
16
|
+
return {
|
|
17
|
+
hash: createHash('sha256').update(content).digest('hex').slice(0, 16),
|
|
18
|
+
size: content.length,
|
|
19
|
+
lines: content.split('\n').length,
|
|
20
|
+
};
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function loadIntegrity() {
|
|
27
|
+
if (!existsSync(INTEGRITY_FILE)) return null;
|
|
28
|
+
try { return JSON.parse(readFileSync(INTEGRITY_FILE, 'utf8')); }
|
|
29
|
+
catch { return null; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function saveIntegrity(data) {
|
|
33
|
+
try {
|
|
34
|
+
mkdirSync(join(homedir(), '.clawarmor'), { recursive: true });
|
|
35
|
+
writeFileSync(INTEGRITY_FILE, JSON.stringify(data, null, 2), 'utf8');
|
|
36
|
+
} catch { /* non-fatal */ }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check config integrity. Call this after a successful (score > 0) audit.
|
|
41
|
+
* Returns { status, changes } where status is 'baseline'|'ok'|'changed'.
|
|
42
|
+
*/
|
|
43
|
+
export function checkIntegrity(configPath, score) {
|
|
44
|
+
const current = hashFile(configPath);
|
|
45
|
+
if (!current) return { status: 'unreadable', changes: [] };
|
|
46
|
+
|
|
47
|
+
const stored = loadIntegrity();
|
|
48
|
+
|
|
49
|
+
if (!stored) {
|
|
50
|
+
// First run — establish baseline (only if clean or near-clean)
|
|
51
|
+
if (score >= 80) {
|
|
52
|
+
saveIntegrity({
|
|
53
|
+
configPath,
|
|
54
|
+
hash: current.hash,
|
|
55
|
+
size: current.size,
|
|
56
|
+
lines: current.lines,
|
|
57
|
+
baselineAt: new Date().toISOString(),
|
|
58
|
+
baselineScore: score,
|
|
59
|
+
});
|
|
60
|
+
return { status: 'baseline', changes: [] };
|
|
61
|
+
}
|
|
62
|
+
return { status: 'no-baseline', changes: [] };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Check for changes
|
|
66
|
+
if (stored.hash === current.hash) {
|
|
67
|
+
return { status: 'ok', changes: [] };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const changes = [];
|
|
71
|
+
if (stored.size !== current.size) {
|
|
72
|
+
const delta = current.size - stored.size;
|
|
73
|
+
changes.push(`Size: ${stored.size} → ${current.size} bytes (${delta > 0 ? '+' : ''}${delta})`);
|
|
74
|
+
}
|
|
75
|
+
if (stored.lines !== current.lines) {
|
|
76
|
+
const delta = current.lines - stored.lines;
|
|
77
|
+
changes.push(`Lines: ${stored.lines} → ${current.lines} (${delta > 0 ? '+' : ''}${delta})`);
|
|
78
|
+
}
|
|
79
|
+
changes.push(`Hash: ${stored.hash} → ${current.hash}`);
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
status: 'changed',
|
|
83
|
+
changes,
|
|
84
|
+
baselineAt: stored.baselineAt,
|
|
85
|
+
baselineScore: stored.baselineScore,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Update baseline after a clean audit (call when user explicitly passes --accept-changes). */
|
|
90
|
+
export function updateBaseline(configPath, score) {
|
|
91
|
+
const current = hashFile(configPath);
|
|
92
|
+
if (!current) return false;
|
|
93
|
+
saveIntegrity({
|
|
94
|
+
configPath,
|
|
95
|
+
hash: current.hash,
|
|
96
|
+
size: current.size,
|
|
97
|
+
lines: current.lines,
|
|
98
|
+
baselineAt: new Date().toISOString(),
|
|
99
|
+
baselineScore: score,
|
|
100
|
+
});
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
@@ -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
|
+
}
|