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 CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
- // ClawArmor v1.2.0 — Security armor for OpenClaw agents
2
+ // ClawArmor v2.0.0-alpha.2 — Security armor for OpenClaw agents
3
3
 
4
4
  import { paint } from './lib/output/colors.js';
5
5
 
6
- const VERSION = '1.2.0';
6
+ const VERSION = '2.0.0-alpha.1';
7
7
  const GATEWAY_PORT_DEFAULT = 18789;
8
8
 
9
9
  function isLocalhost(host) {
@@ -45,6 +45,10 @@ function usage() {
45
45
  console.log(` ${paint.cyan('trend')} Show score over last N audits (ASCII chart)`);
46
46
  console.log(` ${paint.cyan('compare')} Compare coverage vs openclaw security audit`);
47
47
  console.log(` ${paint.cyan('fix')} Auto-apply safe fixes (--dry-run to preview, --apply to run)`);
48
+ console.log(` ${paint.cyan('watch')} Monitor config and skill changes in real time`);
49
+ console.log(` ${paint.cyan('protect')} Install/uninstall/status the full guard system`);
50
+ console.log(` ${paint.cyan('prescan')} Pre-scan a skill before installing it`);
51
+ console.log(` ${paint.cyan('log')} View the audit event log`);
48
52
  console.log('');
49
53
  console.log(` ${paint.dim('Flags:')}`);
50
54
  console.log(` ${paint.dim('--url <host:port>')} Probe a specific host:port instead of 127.0.0.1`);
@@ -153,5 +157,43 @@ if (cmd === 'fix') {
153
157
  process.exit(await runFix(fixFlags));
154
158
  }
155
159
 
160
+ if (cmd === 'watch') {
161
+ const { runWatch } = await import('./lib/watch.js');
162
+ const watchFlags = { daemon: args.includes('--daemon') };
163
+ process.exit(await runWatch(watchFlags));
164
+ }
165
+
166
+ if (cmd === 'protect') {
167
+ const { runProtect } = await import('./lib/protect.js');
168
+ const protectFlags = {
169
+ install: args.includes('--install'),
170
+ uninstall: args.includes('--uninstall'),
171
+ status: args.includes('--status'),
172
+ };
173
+ process.exit(await runProtect(protectFlags));
174
+ }
175
+
176
+ if (cmd === 'prescan') {
177
+ const skillArg = args[1];
178
+ if (!skillArg) {
179
+ console.log(` Usage: clawarmor prescan <skill-name>`);
180
+ process.exit(1);
181
+ }
182
+ const { runPrescan } = await import('./lib/prescan.js');
183
+ process.exit(await runPrescan(skillArg));
184
+ }
185
+
186
+ if (cmd === 'log') {
187
+ const sinceIdx = args.indexOf('--since');
188
+ const sinceArg = sinceIdx !== -1 ? args[sinceIdx + 1] : null;
189
+ const logFlags = {
190
+ json: args.includes('--json'),
191
+ tokens: args.includes('--tokens'),
192
+ since: sinceArg || null,
193
+ };
194
+ const { runLog } = await import('./lib/log-viewer.js');
195
+ process.exit(await runLog(logFlags));
196
+ }
197
+
156
198
  console.log(` ${paint.red('✗')} Unknown command: ${paint.bold(cmd)}`);
157
199
  usage(); process.exit(1);
@@ -0,0 +1,38 @@
1
+ // ClawArmor v2.0 — Security Audit Log
2
+ // Appends one JSONL line per event to ~/.clawarmor/audit.log
3
+ // Schema: { ts, cmd, trigger, score, delta, findings, blocked, skill }
4
+
5
+ import { mkdirSync, appendFileSync } from 'fs';
6
+ import { join } from 'path';
7
+ import { homedir } from 'os';
8
+
9
+ const LOG_DIR = join(homedir(), '.clawarmor');
10
+ const LOG_FILE = join(LOG_DIR, 'audit.log');
11
+
12
+ /**
13
+ * Append one audit event to ~/.clawarmor/audit.log (JSONL format).
14
+ * @param {object} entry
15
+ * @param {string} entry.cmd - 'audit' | 'scan' | 'prescan' | 'watch'
16
+ * @param {string} entry.trigger - 'manual' | 'gateway:startup' | 'watch' | 'prescan'
17
+ * @param {number|null} entry.score - numeric score (audit only)
18
+ * @param {number|null} entry.delta - score change from previous run
19
+ * @param {Array} entry.findings - [{ id, severity }]
20
+ * @param {boolean|null} entry.blocked - prescan blocked install
21
+ * @param {string|null} entry.skill - skill name (prescan / scan per-skill)
22
+ */
23
+ export function append(entry) {
24
+ try {
25
+ mkdirSync(LOG_DIR, { recursive: true });
26
+ const line = JSON.stringify({
27
+ ts: new Date().toISOString(),
28
+ cmd: entry.cmd ?? null,
29
+ trigger: entry.trigger ?? 'manual',
30
+ score: entry.score ?? null,
31
+ delta: entry.delta ?? null,
32
+ findings: Array.isArray(entry.findings) ? entry.findings : [],
33
+ blocked: entry.blocked ?? null,
34
+ skill: entry.skill ?? null,
35
+ }) + '\n';
36
+ appendFileSync(LOG_FILE, line, 'utf8');
37
+ } catch { /* non-fatal — never crash the main command */ }
38
+ }
package/lib/audit.js CHANGED
@@ -11,15 +11,21 @@ import toolChecks from './checks/tools.js';
11
11
  import versionChecks from './checks/version.js';
12
12
  import hooksChecks from './checks/hooks.js';
13
13
  import allowFromChecks from './checks/allowfrom.js';
14
+ import tokenAgeChecks from './checks/token-age.js';
15
+ import execApprovalChecks from './checks/exec-approval.js';
16
+ import skillPinningChecks from './checks/skill-pinning.js';
17
+ import gitCredentialLeakChecks from './checks/git-credential-leak.js';
14
18
  import { writeFileSync, mkdirSync, existsSync, readFileSync, renameSync } from 'fs';
15
19
  import { join } from 'path';
16
20
  import { homedir } from 'os';
17
21
  import { checkIntegrity, updateBaseline } from './integrity.js';
22
+ import { append as auditLogAppend } from './audit-log.js';
23
+ import credentialFilesChecks from './checks/credential-files.js';
18
24
 
19
25
  const W = { CRITICAL: 25, HIGH: 15, MEDIUM: 8, LOW: 3, INFO: 0 };
20
26
  const SEP = paint.dim('─'.repeat(52));
21
27
  const W52 = 52;
22
- const VERSION = '1.2.0';
28
+ const VERSION = '2.0.0-alpha.1';
23
29
 
24
30
  const HISTORY_DIR = join(homedir(), '.clawarmor');
25
31
  const HISTORY_FILE = join(HISTORY_DIR, 'history.json');
@@ -156,6 +162,9 @@ export async function runAudit(flags = {}) {
156
162
  ...gatewayChecks, ...filesystemChecks, ...channelChecks,
157
163
  ...authChecks, ...toolChecks, ...versionChecks, ...hooksChecks,
158
164
  ...allowFromChecks,
165
+ ...tokenAgeChecks, ...execApprovalChecks, ...skillPinningChecks,
166
+ ...gitCredentialLeakChecks,
167
+ ...credentialFilesChecks,
159
168
  ];
160
169
  const staticResults = [];
161
170
  for (const check of allChecks) {
@@ -207,6 +216,18 @@ export async function runAudit(flags = {}) {
207
216
 
208
217
  if (flags.json) {
209
218
  console.log(JSON.stringify({score,grade,failed,passed},null,2));
219
+ const histJ = loadHistory();
220
+ const prevScoreJ = histJ.length ? histJ[histJ.length - 1].score : null;
221
+ const deltaJ = prevScoreJ != null ? score - prevScoreJ : null;
222
+ auditLogAppend({
223
+ cmd: 'audit',
224
+ trigger: 'manual',
225
+ score,
226
+ delta: deltaJ,
227
+ findings: failed.map(f => ({ id: f.id, severity: f.severity })),
228
+ blocked: null,
229
+ skill: null,
230
+ });
210
231
  appendHistory({ timestamp: new Date().toISOString(), score, grade,
211
232
  findings: failed.length, criticals, version: VERSION,
212
233
  failedIds: failed.map(f => f.id) });
@@ -261,7 +282,21 @@ export async function runAudit(flags = {}) {
261
282
  console.log(` ${paint.green('✓')} Config baseline updated.`);
262
283
  }
263
284
 
264
- // Persist history (atomic)
285
+ // Audit log (JSONL) — compute delta from history before writing
286
+ const hist = loadHistory();
287
+ const prevScore = hist.length ? hist[hist.length - 1].score : null;
288
+ const delta = prevScore != null ? score - prevScore : null;
289
+ auditLogAppend({
290
+ cmd: 'audit',
291
+ trigger: 'manual',
292
+ score,
293
+ delta,
294
+ findings: failed.map(f => ({ id: f.id, severity: f.severity })),
295
+ blocked: null,
296
+ skill: null,
297
+ });
298
+
299
+ // Persist history (atomic)
265
300
  appendHistory({
266
301
  timestamp: new Date().toISOString(),
267
302
  score,
@@ -0,0 +1,156 @@
1
+ // T-CRED-001 — Credential File Permission Hygiene
2
+ // Checks ~/.openclaw/ directory and file permissions, and scans JSON
3
+ // files for API key patterns (key names only — never values).
4
+
5
+ import { existsSync, readdirSync, statSync, readFileSync } from 'fs';
6
+ import { join } from 'path';
7
+ import { homedir } from 'os';
8
+
9
+ const HOME = homedir();
10
+ const OPENCLAW_DIR = join(HOME, '.openclaw');
11
+
12
+ // Same pattern shape as git-credential-leak: matches key names + long value
13
+ // We only use this to DETECT presence — we never log the value
14
+ const SECRET_PATTERN = /(?:api[_-]?key|token|secret|password|credential)["']?\s*[:=]\s*["']?([a-zA-Z0-9_\-]{16,})/i;
15
+
16
+ // ── check 1: directory permissions ─────────────────────────────────────────
17
+
18
+ export function checkCredDirPermissions() {
19
+ if (!existsSync(OPENCLAW_DIR)) {
20
+ return { id: 'cred.dir_permissions', severity: 'MEDIUM', passed: true,
21
+ passedMsg: '~/.openclaw/ not found — credential directory check skipped' };
22
+ }
23
+
24
+ let dirStat;
25
+ try { dirStat = statSync(OPENCLAW_DIR); }
26
+ catch {
27
+ return { id: 'cred.dir_permissions', severity: 'MEDIUM', passed: true,
28
+ passedMsg: 'Could not stat ~/.openclaw/ — skipped' };
29
+ }
30
+
31
+ const mode = dirStat.mode & 0o777;
32
+ if (mode > 0o700) {
33
+ return {
34
+ id: 'cred.dir_permissions',
35
+ severity: 'MEDIUM',
36
+ passed: false,
37
+ title: `~/.openclaw/ directory permissions are too open (${mode.toString(8)})`,
38
+ description: `The directory containing your credentials has permissions ${mode.toString(8)}.\nIt should be 700 (owner-only access). Overly permissive directory permissions\nallow other users or groups to list and access your credential files.`,
39
+ fix: `chmod 700 ${OPENCLAW_DIR}`,
40
+ };
41
+ }
42
+
43
+ return { id: 'cred.dir_permissions', severity: 'MEDIUM', passed: true,
44
+ passedMsg: `~/.openclaw/ directory permissions are secure (${mode.toString(8)})` };
45
+ }
46
+
47
+ // ── check 2: file permissions ───────────────────────────────────────────────
48
+
49
+ export function checkCredFilePermissions() {
50
+ if (!existsSync(OPENCLAW_DIR)) {
51
+ return { id: 'cred.file_permissions', severity: 'CRITICAL', passed: true,
52
+ passedMsg: '~/.openclaw/ not found — credential file permission check skipped' };
53
+ }
54
+
55
+ let entries;
56
+ try { entries = readdirSync(OPENCLAW_DIR, { withFileTypes: true }); }
57
+ catch {
58
+ return { id: 'cred.file_permissions', severity: 'CRITICAL', passed: true,
59
+ passedMsg: 'Could not read ~/.openclaw/ — skipped' };
60
+ }
61
+
62
+ const worldReadable = [];
63
+ const groupReadable = [];
64
+
65
+ for (const entry of entries) {
66
+ if (!entry.isFile()) continue;
67
+ const filePath = join(OPENCLAW_DIR, entry.name);
68
+ let s;
69
+ try { s = statSync(filePath); } catch { continue; }
70
+ const mode = s.mode & 0o777;
71
+
72
+ if (mode & 0o004) {
73
+ // world-readable bit set → CRITICAL
74
+ worldReadable.push({ name: entry.name, path: filePath, mode: mode.toString(8) });
75
+ } else if (mode & 0o040) {
76
+ // group-readable bit set (but not world) → HIGH
77
+ groupReadable.push({ name: entry.name, path: filePath, mode: mode.toString(8) });
78
+ }
79
+ }
80
+
81
+ if (worldReadable.length) {
82
+ const fixLines = worldReadable.map(f => ` chmod 600 ${f.path}`).join('\n');
83
+ const fileList = worldReadable.map(f => `• ${f.name} (${f.mode})`).join('\n');
84
+ return {
85
+ id: 'cred.file_permissions',
86
+ severity: 'CRITICAL',
87
+ passed: false,
88
+ title: `World-readable credential files in ~/.openclaw/ (${worldReadable.length})`,
89
+ description: `The following files are readable by any user on the system:\n${fileList}\n\nAny local process or user can read your API keys and tokens.`,
90
+ fix: `Fix immediately:\n${fixLines}`,
91
+ };
92
+ }
93
+
94
+ if (groupReadable.length) {
95
+ const fixLines = groupReadable.map(f => ` chmod 600 ${f.path}`).join('\n');
96
+ const fileList = groupReadable.map(f => `• ${f.name} (${f.mode})`).join('\n');
97
+ return {
98
+ id: 'cred.file_permissions',
99
+ severity: 'HIGH',
100
+ passed: false,
101
+ title: `Group-readable credential files in ~/.openclaw/ (${groupReadable.length})`,
102
+ description: `The following files are readable by your Unix group:\n${fileList}\n\nOther users in your group can read your credentials.`,
103
+ fix: `Fix:\n${fixLines}`,
104
+ };
105
+ }
106
+
107
+ return { id: 'cred.file_permissions', severity: 'CRITICAL', passed: true,
108
+ passedMsg: 'Credential file permissions are secure (all ≤ 0600)' };
109
+ }
110
+
111
+ // ── check 3: JSON content scan for API key patterns ─────────────────────────
112
+
113
+ export function checkCredJsonSecrets() {
114
+ if (!existsSync(OPENCLAW_DIR)) {
115
+ return { id: 'cred.json_secrets', severity: 'HIGH', passed: true,
116
+ passedMsg: '~/.openclaw/ not found — JSON secret pattern check skipped' };
117
+ }
118
+
119
+ let entries;
120
+ try { entries = readdirSync(OPENCLAW_DIR, { withFileTypes: true }); }
121
+ catch {
122
+ return { id: 'cred.json_secrets', severity: 'HIGH', passed: true,
123
+ passedMsg: 'Could not read ~/.openclaw/ — skipped' };
124
+ }
125
+
126
+ const flagged = [];
127
+
128
+ for (const entry of entries) {
129
+ if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
130
+ const filePath = join(OPENCLAW_DIR, entry.name);
131
+ let content;
132
+ try { content = readFileSync(filePath, 'utf8'); }
133
+ catch { continue; }
134
+ if (content.length > 1_000_000) continue;
135
+
136
+ if (SECRET_PATTERN.test(content)) {
137
+ flagged.push(entry.name);
138
+ }
139
+ }
140
+
141
+ if (!flagged.length) {
142
+ return { id: 'cred.json_secrets', severity: 'HIGH', passed: true,
143
+ passedMsg: 'No API key patterns found in ~/.openclaw/ JSON files' };
144
+ }
145
+
146
+ return {
147
+ id: 'cred.json_secrets',
148
+ severity: 'HIGH',
149
+ passed: false,
150
+ title: `API key patterns found in ~/.openclaw/ JSON files (${flagged.length} file${flagged.length > 1 ? 's' : ''})`,
151
+ description: `The following JSON files in ~/.openclaw/ contain patterns matching API keys or secrets:\n${flagged.map(f => `• ${f}`).join('\n')}\n\nNote: Only key name patterns are detected — actual values are never read or stored.\nCredentials in the wrong files may be at risk if file permissions are too open.`,
152
+ fix: `Ensure all credential files use 0600 permissions:\n chmod 600 ~/.openclaw/*.json\n\nIf credentials are in unexpected files, move them to agent-accounts.json.`,
153
+ };
154
+ }
155
+
156
+ export default [checkCredDirPermissions, checkCredFilePermissions, checkCredJsonSecrets];
@@ -0,0 +1,64 @@
1
+ // T-EXEC-004 — Exec Approval Coverage
2
+ // Checks that exec commands require user approval.
3
+
4
+ import { get } from '../config.js';
5
+
6
+ export function checkExecApproval(config) {
7
+ const ask = get(config, 'tools.exec.ask', null);
8
+ const allowed = get(config, 'tools.exec.allowed', null);
9
+ const hasAllowlist = Array.isArray(allowed) && allowed.length > 0;
10
+
11
+ // ask === 'off' — no approvals at all
12
+ if (ask === 'off') {
13
+ return {
14
+ id: 'exec.approval',
15
+ severity: 'HIGH',
16
+ passed: false,
17
+ title: 'Exec approval disabled — all shell commands run without confirmation',
18
+ description: `tools.exec.ask="off" means every shell command the agent triggers\nruns immediately with zero user approval. Any prompt injection or malicious\nskill can execute arbitrary commands on your system without you seeing them.\nAttack: attacker injects "run rm -rf ~/important" — it executes silently.`,
19
+ fix: `openclaw config set tools.exec.ask always\n# or, to allow a specific set without prompts:\nopenctl config set tools.exec.ask on-miss\nopenctl config set tools.exec.allowed '["git","npm","node"]'`,
20
+ };
21
+ }
22
+
23
+ // ask === 'on-miss' with no allowlist — unbounded
24
+ if (ask === 'on-miss' && !hasAllowlist) {
25
+ return {
26
+ id: 'exec.approval',
27
+ severity: 'MEDIUM',
28
+ passed: false,
29
+ title: 'Exec approval set to on-miss but no allowlist defined',
30
+ description: `tools.exec.ask="on-miss" only prompts for commands not on the allowed list.\nWith no allowed list set, the effective behaviour depends on how openclaw handles\nan empty list — this is ambiguous and may allow all commands silently.\nAttack: attacker runs any command that happens to be implicitly allowed.`,
31
+ fix: `Either require approval for everything:\n openclaw config set tools.exec.ask always\nOr define an explicit allowlist:\n openclaw config set tools.exec.allowed '["git","npm","node"]'`,
32
+ };
33
+ }
34
+
35
+ // ask === 'always' — best practice
36
+ if (ask === 'always') {
37
+ return { id: 'exec.approval', severity: 'HIGH', passed: true,
38
+ passedMsg: 'Exec approval set to always — all commands require confirmation' };
39
+ }
40
+
41
+ // ask === 'on-miss' with a non-empty allowlist — acceptable
42
+ if (ask === 'on-miss' && hasAllowlist) {
43
+ return { id: 'exec.approval', severity: 'HIGH', passed: true,
44
+ passedMsg: `Exec approval: on-miss with ${allowed.length}-command allowlist` };
45
+ }
46
+
47
+ // ask is null/undefined — default behaviour is unknown; treat as warn
48
+ if (ask == null) {
49
+ return {
50
+ id: 'exec.approval',
51
+ severity: 'MEDIUM',
52
+ passed: false,
53
+ title: 'Exec approval not explicitly configured',
54
+ description: `tools.exec.ask is not set. The default approval behaviour is unknown\nand may change across openclaw versions. Explicit configuration is safer.`,
55
+ fix: `openclaw config set tools.exec.ask always`,
56
+ };
57
+ }
58
+
59
+ // Unknown value — pass with info
60
+ return { id: 'exec.approval', severity: 'HIGH', passed: true,
61
+ passedMsg: `Exec approval: ask="${ask}"` };
62
+ }
63
+
64
+ export default [checkExecApproval];
@@ -0,0 +1,135 @@
1
+ // T-EXFIL-003 — Workspace Git Credential Leak
2
+ // Scans the workspace git history and .env files for committed secrets.
3
+ // NEVER prints actual secret values — only reports key names and locations.
4
+
5
+ import { existsSync, readdirSync, readFileSync } from 'fs';
6
+ import { join, basename } from 'path';
7
+ import { homedir } from 'os';
8
+ import { execSync } from 'child_process';
9
+ import { get } from '../config.js';
10
+
11
+ const HOME = homedir();
12
+ const DEFAULT_WORKSPACE = join(HOME, 'clawd');
13
+
14
+ // Pattern: key/token/secret = "value_at_least_16_chars"
15
+ // NEVER logs the value; only the matched key name
16
+ const SECRET_PATTERN = /(?:api[_-]?key|token|secret|password|credential)["']?\s*[:=]\s*["']?([a-zA-Z0-9_\-]{16,})/i;
17
+ // Pattern to find the key name from the line (for reporting)
18
+ const KEY_NAME_PATTERN = /^[^=:]*?(api[_-]?key|token|secret|password|credential)/i;
19
+
20
+ const ENV_FILE_NAMES = ['.env', '.env.local', '.env.production', '.env.staging', '.env.development'];
21
+
22
+ function isGitRepo(dir) {
23
+ return existsSync(join(dir, '.git'));
24
+ }
25
+
26
+ function scanEnvFiles(workspaceDir) {
27
+ const findings = [];
28
+
29
+ // Scan workspace root for .env files
30
+ let files;
31
+ try { files = readdirSync(workspaceDir); }
32
+ catch { return findings; }
33
+
34
+ for (const filename of files) {
35
+ if (!ENV_FILE_NAMES.includes(filename)) continue;
36
+ const filePath = join(workspaceDir, filename);
37
+ let lines;
38
+ try {
39
+ lines = readFileSync(filePath, 'utf8').split('\n');
40
+ } catch { continue; }
41
+
42
+ for (let i = 0; i < lines.length; i++) {
43
+ const line = lines[i];
44
+ if (SECRET_PATTERN.test(line)) {
45
+ const keyMatch = line.match(KEY_NAME_PATTERN);
46
+ const keyName = keyMatch ? keyMatch[0].trim().replace(/["']/g, '') : 'unknown-key';
47
+ findings.push(`${filename}:${i + 1} — key matching "${keyName.slice(0, 40)}"`);
48
+ }
49
+ }
50
+ }
51
+
52
+ return findings;
53
+ }
54
+
55
+ function scanGitLog(workspaceDir) {
56
+ const findings = [];
57
+
58
+ try {
59
+ // Get last 50 commits' diffs — limit output to avoid huge repos
60
+ const output = execSync(
61
+ 'git log -p --max-count=50 --no-color --unified=0',
62
+ { cwd: workspaceDir, timeout: 15000, maxBuffer: 5 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }
63
+ ).toString('utf8');
64
+
65
+ const lines = output.split('\n');
66
+ let currentCommit = '';
67
+ let currentFile = '';
68
+
69
+ for (let i = 0; i < lines.length; i++) {
70
+ const line = lines[i];
71
+
72
+ if (line.startsWith('commit ')) {
73
+ currentCommit = line.slice(7, 15); // short hash
74
+ continue;
75
+ }
76
+ if (line.startsWith('+++ b/')) {
77
+ currentFile = line.slice(6);
78
+ continue;
79
+ }
80
+ // Only check added lines (prefixed with +)
81
+ if (!line.startsWith('+') || line.startsWith('+++')) continue;
82
+
83
+ if (SECRET_PATTERN.test(line)) {
84
+ const keyMatch = line.match(KEY_NAME_PATTERN);
85
+ const keyName = keyMatch ? keyMatch[0].trim().replace(/["']/g, '') : 'unknown-key';
86
+ const location = `commit ${currentCommit}, file ${currentFile || 'unknown'}, line pattern "${keyName.slice(0, 40)}"`;
87
+ if (!findings.includes(location)) {
88
+ findings.push(location);
89
+ }
90
+ }
91
+ }
92
+ } catch {
93
+ // git not available, not a git repo, or timeout — skip silently
94
+ }
95
+
96
+ return findings;
97
+ }
98
+
99
+ export async function checkGitCredentialLeak(config) {
100
+ // Resolve workspace directory from config or default
101
+ const workspaceDir = get(config, 'workspace.dir', null) || DEFAULT_WORKSPACE;
102
+
103
+ if (!existsSync(workspaceDir)) {
104
+ return { id: 'exfil.git_credential_leak', severity: 'CRITICAL', passed: true,
105
+ passedMsg: 'Workspace directory not found — git credential leak check skipped' };
106
+ }
107
+
108
+ const envFindings = scanEnvFiles(workspaceDir);
109
+
110
+ let gitFindings = [];
111
+ if (isGitRepo(workspaceDir)) {
112
+ gitFindings = scanGitLog(workspaceDir);
113
+ }
114
+
115
+ const allFindings = [...envFindings, ...gitFindings];
116
+
117
+ if (!allFindings.length) {
118
+ return { id: 'exfil.git_credential_leak', severity: 'CRITICAL', passed: true,
119
+ passedMsg: 'No credential patterns found in workspace .env files or git history' };
120
+ }
121
+
122
+ const list = allFindings.slice(0, 10).map(f => `• ${f}`).join('\n');
123
+ const more = allFindings.length > 10 ? `\n ...and ${allFindings.length - 10} more` : '';
124
+
125
+ return {
126
+ id: 'exfil.git_credential_leak',
127
+ severity: 'CRITICAL',
128
+ passed: false,
129
+ title: `Credential patterns found in workspace (${allFindings.length} location${allFindings.length > 1 ? 's' : ''})`,
130
+ description: `Secret-like patterns matching credential keys were found in your workspace.\nThis may mean API keys or tokens were committed to git or left in .env files.\n\n${list}${more}\n\nNote: Only key names are shown — no actual values are printed.`,
131
+ fix: `For .env files: add them to .gitignore immediately:\n echo '.env*' >> ${workspaceDir}/.gitignore\n\nFor committed secrets, scrub git history:\n git filter-repo --path-glob '*.env' --invert-paths\n # or: https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/removing-sensitive-data-from-a-repository\n\nRotate any exposed credentials immediately.`,
132
+ };
133
+ }
134
+
135
+ export default [checkGitCredentialLeak];