clawarmor 1.2.0 → 2.0.0-alpha.3
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 +66 -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/digest.js +157 -0
- package/lib/harden.js +312 -0
- package/lib/log-viewer.js +140 -0
- package/lib/output/progress.js +2 -1
- package/lib/prescan.js +167 -0
- package/lib/protect.js +352 -0
- package/lib/scan.js +14 -0
- package/lib/status.js +250 -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];
|
package/lib/digest.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// clawarmor digest — Weekly security digest + cron job installer.
|
|
2
|
+
// Reads ~/.clawarmor/audit.log for past 7 days and outputs a formatted summary.
|
|
3
|
+
|
|
4
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync, renameSync } from 'fs';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import { homedir } from 'os';
|
|
7
|
+
import { paint } from './output/colors.js';
|
|
8
|
+
import { scoreToGrade } from './output/progress.js';
|
|
9
|
+
|
|
10
|
+
const HOME = homedir();
|
|
11
|
+
const OC_DIR = join(HOME, '.openclaw');
|
|
12
|
+
const CLAWARMOR_DIR = join(HOME, '.clawarmor');
|
|
13
|
+
const AUDIT_LOG = join(CLAWARMOR_DIR, 'audit.log');
|
|
14
|
+
const HISTORY_FILE = join(CLAWARMOR_DIR, 'history.json');
|
|
15
|
+
const CRON_DIR = join(OC_DIR, 'cron');
|
|
16
|
+
const CRON_JOBS_FILE = join(CRON_DIR, 'jobs.json');
|
|
17
|
+
|
|
18
|
+
// ── Cron installer ────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
export function installDigestCron() {
|
|
21
|
+
try {
|
|
22
|
+
mkdirSync(CRON_DIR, { recursive: true });
|
|
23
|
+
|
|
24
|
+
let jobs = [];
|
|
25
|
+
if (existsSync(CRON_JOBS_FILE)) {
|
|
26
|
+
try { jobs = JSON.parse(readFileSync(CRON_JOBS_FILE, 'utf8')); }
|
|
27
|
+
catch { jobs = []; }
|
|
28
|
+
if (!Array.isArray(jobs)) jobs = [];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Remove existing entry with same ID to avoid duplicates
|
|
32
|
+
jobs = jobs.filter(j => j.id !== 'clawarmor-weekly-digest');
|
|
33
|
+
|
|
34
|
+
jobs.push({
|
|
35
|
+
id: 'clawarmor-weekly-digest',
|
|
36
|
+
schedule: '0 9 * * 0',
|
|
37
|
+
task: 'clawarmor digest',
|
|
38
|
+
announce: true,
|
|
39
|
+
deliver: 'main',
|
|
40
|
+
description: 'ClawArmor weekly security digest — every Sunday at 9am',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const tmp = CRON_JOBS_FILE + '.tmp';
|
|
44
|
+
writeFileSync(tmp, JSON.stringify(jobs, null, 2), 'utf8');
|
|
45
|
+
renameSync(tmp, CRON_JOBS_FILE);
|
|
46
|
+
return true;
|
|
47
|
+
} catch { return false; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Log parsing ───────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
function parseLog() {
|
|
53
|
+
if (!existsSync(AUDIT_LOG)) return [];
|
|
54
|
+
try {
|
|
55
|
+
return readFileSync(AUDIT_LOG, 'utf8')
|
|
56
|
+
.split('\n')
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
|
59
|
+
.filter(Boolean);
|
|
60
|
+
} catch { return []; }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readHistory() {
|
|
64
|
+
try {
|
|
65
|
+
if (!existsSync(HISTORY_FILE)) return [];
|
|
66
|
+
return JSON.parse(readFileSync(HISTORY_FILE, 'utf8')) || [];
|
|
67
|
+
} catch { return []; }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function formatDateRange(from, to) {
|
|
71
|
+
const opts = { month: 'short', day: 'numeric' };
|
|
72
|
+
const f = from.toLocaleDateString('en-US', opts);
|
|
73
|
+
const t = to.toLocaleDateString('en-US', opts);
|
|
74
|
+
const year = to.getFullYear();
|
|
75
|
+
return `${f} – ${t}, ${year}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function nextSunday() {
|
|
79
|
+
const now = new Date();
|
|
80
|
+
const day = now.getDay();
|
|
81
|
+
const daysUntil = day === 0 ? 7 : 7 - day;
|
|
82
|
+
const next = new Date(now);
|
|
83
|
+
next.setDate(now.getDate() + daysUntil);
|
|
84
|
+
next.setHours(9, 0, 0, 0);
|
|
85
|
+
return next.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Main export ───────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
export async function runDigest() {
|
|
91
|
+
const now = new Date();
|
|
92
|
+
const weekAgo = new Date(now.getTime() - 7 * 86_400_000);
|
|
93
|
+
|
|
94
|
+
const allEntries = parseLog();
|
|
95
|
+
const weekEntries = allEntries.filter(e => new Date(e.ts) >= weekAgo);
|
|
96
|
+
|
|
97
|
+
const history = readHistory();
|
|
98
|
+
const weekAgoHistory = [...history].reverse().find(h => new Date(h.timestamp) < weekAgo);
|
|
99
|
+
|
|
100
|
+
// Current score from latest history entry
|
|
101
|
+
const latestHistory = history[history.length - 1];
|
|
102
|
+
const currentScore = latestHistory?.score ?? null;
|
|
103
|
+
const currentGrade = latestHistory?.grade ?? (currentScore != null ? scoreToGrade(currentScore) : null);
|
|
104
|
+
const prevScore = weekAgoHistory?.score ?? null;
|
|
105
|
+
const scoreDelta = (currentScore != null && prevScore != null) ? currentScore - prevScore : null;
|
|
106
|
+
|
|
107
|
+
// Stats from this week's log entries
|
|
108
|
+
const auditsRun = weekEntries.filter(e => e.cmd === 'audit').length;
|
|
109
|
+
const skillsScanned = weekEntries.filter(e => e.cmd === 'scan' || e.cmd === 'prescan').length;
|
|
110
|
+
const incidents = weekEntries.filter(e =>
|
|
111
|
+
Array.isArray(e.findings) && e.findings.some(f => f.severity === 'CRITICAL')
|
|
112
|
+
).length;
|
|
113
|
+
const configChanges = weekEntries.filter(e => e.trigger === 'watch').length;
|
|
114
|
+
|
|
115
|
+
// Collect still-open findings from latest audit history entry
|
|
116
|
+
const openFindings = latestHistory?.failedIds ?? [];
|
|
117
|
+
|
|
118
|
+
// ── Output ─────────────────────────────────────────────────────────────────
|
|
119
|
+
const dateRange = formatDateRange(weekAgo, now);
|
|
120
|
+
const arrowStr = scoreDelta != null
|
|
121
|
+
? (scoreDelta > 0 ? `↑+${scoreDelta}` : scoreDelta < 0 ? `↓${scoreDelta}` : '→±0')
|
|
122
|
+
: '';
|
|
123
|
+
|
|
124
|
+
console.log('');
|
|
125
|
+
console.log(` 🛡 ClawArmor Weekly — ${dateRange}`);
|
|
126
|
+
console.log('');
|
|
127
|
+
|
|
128
|
+
if (currentScore != null) {
|
|
129
|
+
const scoreStr = `${currentScore}/100 ${currentGrade}`;
|
|
130
|
+
const deltaStr = arrowStr ? ` ${arrowStr} vs last week` : '';
|
|
131
|
+
console.log(` Security posture: ${scoreStr}${deltaStr}`);
|
|
132
|
+
} else {
|
|
133
|
+
console.log(` Security posture: no data — run: clawarmor audit`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
console.log(` Skills installed: ${skillsScanned} scanned this week`);
|
|
137
|
+
console.log(` Config changes: ${configChanges}`);
|
|
138
|
+
console.log(` Audits run: ${auditsRun}`);
|
|
139
|
+
console.log(` Incidents: ${incidents}`);
|
|
140
|
+
|
|
141
|
+
if (openFindings.length) {
|
|
142
|
+
console.log('');
|
|
143
|
+
console.log(` Recommendations:`);
|
|
144
|
+
for (const id of openFindings.slice(0, 5)) {
|
|
145
|
+
console.log(` • ${id}`);
|
|
146
|
+
}
|
|
147
|
+
if (openFindings.length > 5) {
|
|
148
|
+
console.log(` • … and ${openFindings.length - 5} more (run: clawarmor audit)`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
console.log('');
|
|
153
|
+
console.log(` Next digest: ${nextSunday()}`);
|
|
154
|
+
console.log('');
|
|
155
|
+
|
|
156
|
+
return 0;
|
|
157
|
+
}
|