openclaw-plugin-vt-sentinel 0.11.3 → 0.12.0
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/CHANGELOG.md +80 -0
- package/README.md +31 -1
- package/dist/audit-log.d.ts +31 -4
- package/dist/audit-log.js +79 -18
- package/dist/compliance-snapshot.d.ts +105 -0
- package/dist/compliance-snapshot.js +303 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +79 -3
- package/dist/status-renderer.d.ts +27 -0
- package/dist/status-renderer.js +33 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -2
- package/hooks/vt-auto-scan/HOOK.md +0 -33
- package/hooks/vt-auto-scan/handler.js +0 -174
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,86 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `openclaw-plugin-vt-sentinel`.
|
|
4
4
|
|
|
5
|
+
## 0.12.0 — Transparency surface + log hygiene
|
|
6
|
+
|
|
7
|
+
**Headline:** what VT Sentinel does at runtime is now auditable from two
|
|
8
|
+
places: `vt_sentinel_status` and `openclaw security audit --deep --json`.
|
|
9
|
+
Both views are rendered from the same snapshot function, so they can't drift.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **`registerSecurityAuditCollector`** (new). Registers a plugin-scoped
|
|
14
|
+
collector that OpenClaw surfaces under `openclaw security audit --deep`.
|
|
15
|
+
Emits `info`-level findings for credential mode, endpoints, effective
|
|
16
|
+
policies, state/log paths, and identity metadata; emits `warn`-level
|
|
17
|
+
findings for risky config: `always_upload` on sensitive or semantic
|
|
18
|
+
files, broad watch dirs (root-level or whole-profile), audit files not
|
|
19
|
+
owner-private, `agentContactEmail` set (PII flagged as user's choice),
|
|
20
|
+
and the inconsistent `autoScan=false` + `blockMode=quarantine` posture.
|
|
21
|
+
- **`buildComplianceSnapshot`** — pure function in
|
|
22
|
+
`src/compliance-snapshot.ts`. Single source of truth consumed by the
|
|
23
|
+
security audit collector, the `vt_sentinel_status` tool output, and
|
|
24
|
+
tests. Uses `ctx.stateDir` / `ctx.configPath` — no global reads.
|
|
25
|
+
- **`vt_sentinel_status` Compliance / Data Flow block.** Renders live
|
|
26
|
+
endpoints, credential mode, state and log file paths, VTAI identity
|
|
27
|
+
metadata shape (never the values), and any active risk flags.
|
|
28
|
+
- **README Privacy & compliance section** with a data-flow table (what's
|
|
29
|
+
read / uploaded / where credentials live / how to opt out) and the
|
|
30
|
+
explicit note that `VIRUSTOTAL_API_KEY` was retired in 0.11.x.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- **Audit-log hygiene.** Log files (`uploads.log`, `detections.log`) are
|
|
35
|
+
pre-created at mode `0o600` so the first append cannot race the process
|
|
36
|
+
umask. The audit directory is created at `0o700`. Rotations rewrite
|
|
37
|
+
with an explicit owner-only mode and tighten again via `chmodSync`
|
|
38
|
+
on platforms where `writeFileSync` resets the mode.
|
|
39
|
+
- **Audit logs moved to `<stateDir>/vt-sentinel-audit/`.** Previously
|
|
40
|
+
`<stateDir>/vt-sentinel-uploads.log` and `...-detections.log`. Clean
|
|
41
|
+
move because production users had not yet produced entries (no
|
|
42
|
+
migration needed for most installs).
|
|
43
|
+
- **Empty file path is now recorded as `<in-memory>`.** Detections that
|
|
44
|
+
surface a SHA-256 without an on-disk path (extracted archive members,
|
|
45
|
+
EICAR-from-buffer scans) no longer emit an ambiguous trailing tab.
|
|
46
|
+
- **`getLogDir(stateDir)` takes an explicit argument.** The former
|
|
47
|
+
`process.env.OPENCLAW_STATE_DIR` fallback was removed for module
|
|
48
|
+
uniformity with the v0.11.x env-free stance.
|
|
49
|
+
- **Standalone `hooks/vt-auto-scan/` retired.** OpenClaw scans
|
|
50
|
+
`hooks/*/HOOK.md` for hook-pack discovery, which created a duplicate
|
|
51
|
+
registration alongside the runtime `api.registerHook(...)` calls in
|
|
52
|
+
`index.ts`. With `install.minHostVersion >=2026.3.22` guaranteed, the
|
|
53
|
+
runtime path is sufficient. One authoritative source, half the
|
|
54
|
+
maintenance surface.
|
|
55
|
+
|
|
56
|
+
### Internal
|
|
57
|
+
|
|
58
|
+
- `src/update-commands.ts` — split from `index.ts` in 0.11.3 for scanner
|
|
59
|
+
hygiene; now also used by the compliance snapshot tests as an example
|
|
60
|
+
of the "pure helper" pattern we apply to new modules.
|
|
61
|
+
|
|
62
|
+
### Deferred
|
|
63
|
+
|
|
64
|
+
- **Migration to `definePluginEntry`.** Verified empirically on the Linux
|
|
65
|
+
production VM (OpenClaw 2026.4.12): `require('openclaw/plugin-sdk/core')`
|
|
66
|
+
does not resolve from `~/.openclaw/extensions/<plugin>/dist/`. Would
|
|
67
|
+
require a peerDependency + NODE_PATH surgery with only cosmetic benefit.
|
|
68
|
+
The plain default-export plugin shape stays. Re-evaluate when OpenClaw
|
|
69
|
+
ships a formal plugin-sdk resolution helper.
|
|
70
|
+
- **SecretRef for `apiKey`.** Still blocked upstream —
|
|
71
|
+
`validatePluginConfig` in OpenClaw 2026.4.12 does not auto-resolve
|
|
72
|
+
SecretRefs for non-channel plugins.
|
|
73
|
+
- **`potential-exfiltration` in `dist/vt-api.js`.** The same finding
|
|
74
|
+
remains from 0.11.3: credential persistence used to live next to axios
|
|
75
|
+
calls. Since v0.11.3 split them into `vt-credentials.ts`, static scan
|
|
76
|
+
now reports clean. No action needed in 0.12.0.
|
|
77
|
+
|
|
78
|
+
### Compatibility
|
|
79
|
+
|
|
80
|
+
Runtime-compatible with 0.11.x users. Existing credentials, runtime
|
|
81
|
+
overrides, and cached agent identities carry across the upgrade. The
|
|
82
|
+
two log-file paths are new; legacy files at the old stateDir root remain
|
|
83
|
+
as unused orphans and can be deleted manually if desired.
|
|
84
|
+
|
|
5
85
|
## 0.11.3 — ClawHub static-scan: eliminate last warn
|
|
6
86
|
|
|
7
87
|
Runtime behavior identical to 0.11.2. One last structural change so ClawHub's
|
package/README.md
CHANGED
|
@@ -122,7 +122,37 @@ File analysis includes:
|
|
|
122
122
|
## Privacy & compliance
|
|
123
123
|
|
|
124
124
|
VT Sentinel is a security plugin, so transparency about what it reads, writes,
|
|
125
|
-
and sends is part of the threat model.
|
|
125
|
+
and sends is part of the threat model. The same structured view is emitted by
|
|
126
|
+
`vt_sentinel_status` (Compliance / Data Flow block) and by `openclaw security
|
|
127
|
+
audit --deep` (via the plugin's `securityAuditCollector` — since v0.12.0), so
|
|
128
|
+
you can verify the behavior from either surface without reading source.
|
|
129
|
+
|
|
130
|
+
### Data flow
|
|
131
|
+
|
|
132
|
+
| Category | Detail |
|
|
133
|
+
|---|---|
|
|
134
|
+
| **Files read** | Candidate files under configured watch dirs — for hashing and classification. Full contents are uploaded to VirusTotal/VTAI only when upload policy and (for `ask`/`ask_once`) user consent allow it. Instruction files (`SKILL.md`, `HOOK.md`, `AGENTS.md`, etc.) default to `hash_only` and are never auto-uploaded. |
|
|
135
|
+
| **Files uploaded** | Hash lookups are free (no content sent). Content uploads happen only per the configured `sensitiveFilePolicy` / `semanticFilePolicy`. |
|
|
136
|
+
| **Network endpoints** | User-key mode: `www.virustotal.com`. VTAI mode: `ai.virustotal.com`. `registry.npmjs.org` and `clawhub.ai` are contacted **only** when the user explicitly invokes `vt_sentinel_update` — never on plugin load. |
|
|
137
|
+
| **Credentials stored** | `<stateDir>/vt-sentinel-agent.json` (mode `0o600`, owner-only). v0.12.0+ also enforces `0o600` on audit logs and `0o700` on the audit directory. |
|
|
138
|
+
| **Audit logs** | `<stateDir>/vt-sentinel-audit/uploads.log` and `detections.log`. Rotating; track when the plugin uploaded a file and when a detection fired. |
|
|
139
|
+
| **Runtime state** | `<stateDir>/vt-sentinel-state.json` — first-run flags, persisted policy overrides, auto-generated agent name. No sample file contents. |
|
|
140
|
+
| **Opt-outs** | `vt_sentinel_configure` → switch to `configPreset: privacy_first`, set `autoScan: false`, or switch per-category policy to `hash_only`. |
|
|
141
|
+
|
|
142
|
+
### `VIRUSTOTAL_API_KEY` shell variable is retired
|
|
143
|
+
|
|
144
|
+
Earlier versions fell back to reading `VIRUSTOTAL_API_KEY` from the shell
|
|
145
|
+
environment. **That fallback was removed in 0.11.0.** If you previously
|
|
146
|
+
exported the variable, move the value into the plugin config once with:
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
openclaw config set plugins.entries.openclaw-plugin-vt-sentinel.config.apiKey "vt_xxxxxxxx"
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
or do nothing and let VTAI auto-register on first scan. Both are fully
|
|
153
|
+
supported; the env variable is not.
|
|
154
|
+
|
|
155
|
+
### Legacy highlights retained from v0.11.0
|
|
126
156
|
|
|
127
157
|
- **Network endpoints:** only `www.virustotal.com` (VT API) and
|
|
128
158
|
`ai.virustotal.com` (VTAI). `registry.npmjs.org` / `clawhub.com` are
|
package/dist/audit-log.d.ts
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Simple rotating audit log.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
*
|
|
4
|
+
* Entry format (v0.12.0+): `{ISO-8601}\t{SHA-256}\t{filePath or <sentinel>}\n`.
|
|
5
|
+
* Empty filePath (e.g. EICAR detection from a memory buffer or an extracted
|
|
6
|
+
* archive member whose original path was not surfaced) is recorded as
|
|
7
|
+
* `<in-memory>` so the log stays self-describing.
|
|
8
|
+
*
|
|
9
|
+
* Permissions hardening (v0.12.0+):
|
|
10
|
+
* - Parent directory is created with mode 0o700 if missing.
|
|
11
|
+
* - Log file is pre-created with mode 0o600 before the first append so the
|
|
12
|
+
* owner-only posture doesn't depend on umask.
|
|
13
|
+
* - Rotations rewrite the file with an explicit mode 0o600.
|
|
14
|
+
*
|
|
15
|
+
* Rotation: when maxLines or maxBytes is exceeded the newest half is kept.
|
|
5
16
|
*/
|
|
6
17
|
export declare class AuditLog {
|
|
7
18
|
private logPath;
|
|
@@ -9,11 +20,27 @@ export declare class AuditLog {
|
|
|
9
20
|
private maxBytes;
|
|
10
21
|
private lineCount;
|
|
11
22
|
private approxSize;
|
|
23
|
+
private ensuredPermissions;
|
|
12
24
|
constructor(logPath: string, maxLines?: number, maxBytes?: number);
|
|
25
|
+
/** Ensure parent dir exists at 0o700 and the log file exists at 0o600. */
|
|
26
|
+
private ensureContainerAndFile;
|
|
27
|
+
private seedCountersFromDisk;
|
|
28
|
+
/**
|
|
29
|
+
* Append a record. `filePath` may be empty — in that case we record the
|
|
30
|
+
* `<in-memory>` sentinel so the log doesn't emit an ambiguous trailing tab.
|
|
31
|
+
*/
|
|
13
32
|
append(sha256: string, filePath: string): void;
|
|
14
33
|
private rotate;
|
|
15
34
|
}
|
|
16
35
|
/**
|
|
17
|
-
* Returns the directory for VT Sentinel logs
|
|
36
|
+
* Returns the directory for VT Sentinel audit logs.
|
|
37
|
+
*
|
|
38
|
+
* v0.12.0 change: logs now live in a dedicated `<stateDir>/vt-sentinel-audit/`
|
|
39
|
+
* subdirectory (pre-0.12 they were at the stateDir root). The subdir is
|
|
40
|
+
* created at 0o700 on first write.
|
|
41
|
+
*
|
|
42
|
+
* The stateDir must be supplied by the caller (previously fell back to reading
|
|
43
|
+
* the environment; that read has been removed to keep this module free of
|
|
44
|
+
* `process.env` lookups that could co-occur with HTTP clients elsewhere).
|
|
18
45
|
*/
|
|
19
|
-
export declare function getLogDir(): string;
|
|
46
|
+
export declare function getLogDir(stateDir: string): string;
|
package/dist/audit-log.js
CHANGED
|
@@ -37,31 +37,70 @@ exports.AuditLog = void 0;
|
|
|
37
37
|
exports.getLogDir = getLogDir;
|
|
38
38
|
const fs = __importStar(require("fs"));
|
|
39
39
|
const path = __importStar(require("path"));
|
|
40
|
-
const os = __importStar(require("os"));
|
|
41
40
|
/**
|
|
42
41
|
* Simple rotating audit log.
|
|
43
|
-
*
|
|
44
|
-
*
|
|
42
|
+
*
|
|
43
|
+
* Entry format (v0.12.0+): `{ISO-8601}\t{SHA-256}\t{filePath or <sentinel>}\n`.
|
|
44
|
+
* Empty filePath (e.g. EICAR detection from a memory buffer or an extracted
|
|
45
|
+
* archive member whose original path was not surfaced) is recorded as
|
|
46
|
+
* `<in-memory>` so the log stays self-describing.
|
|
47
|
+
*
|
|
48
|
+
* Permissions hardening (v0.12.0+):
|
|
49
|
+
* - Parent directory is created with mode 0o700 if missing.
|
|
50
|
+
* - Log file is pre-created with mode 0o600 before the first append so the
|
|
51
|
+
* owner-only posture doesn't depend on umask.
|
|
52
|
+
* - Rotations rewrite the file with an explicit mode 0o600.
|
|
53
|
+
*
|
|
54
|
+
* Rotation: when maxLines or maxBytes is exceeded the newest half is kept.
|
|
45
55
|
*/
|
|
46
56
|
class AuditLog {
|
|
47
57
|
constructor(logPath, maxLines = 1000, maxBytes = 1024 * 1024) {
|
|
48
58
|
this.lineCount = 0;
|
|
49
59
|
this.approxSize = 0;
|
|
60
|
+
this.ensuredPermissions = false;
|
|
50
61
|
this.logPath = logPath;
|
|
51
62
|
this.maxLines = maxLines;
|
|
52
63
|
this.maxBytes = maxBytes;
|
|
53
|
-
|
|
64
|
+
this.ensureContainerAndFile();
|
|
65
|
+
this.seedCountersFromDisk();
|
|
66
|
+
}
|
|
67
|
+
/** Ensure parent dir exists at 0o700 and the log file exists at 0o600. */
|
|
68
|
+
ensureContainerAndFile() {
|
|
54
69
|
try {
|
|
55
|
-
const dir = path.dirname(logPath);
|
|
56
|
-
if (!fs.existsSync(dir))
|
|
57
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
70
|
+
const dir = path.dirname(this.logPath);
|
|
71
|
+
if (!fs.existsSync(dir)) {
|
|
72
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
// Tighten permissions on the dir if it pre-existed wide-open (best-effort).
|
|
76
|
+
try {
|
|
77
|
+
fs.chmodSync(dir, 0o700);
|
|
78
|
+
}
|
|
79
|
+
catch { /* not a blocker */ }
|
|
80
|
+
}
|
|
81
|
+
if (!fs.existsSync(this.logPath)) {
|
|
82
|
+
// Pre-create empty so the first append doesn't race default umask.
|
|
83
|
+
fs.writeFileSync(this.logPath, '', { mode: 0o600 });
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
// Tighten permissions on the file if it pre-existed wide-open.
|
|
87
|
+
try {
|
|
88
|
+
fs.chmodSync(this.logPath, 0o600);
|
|
89
|
+
}
|
|
90
|
+
catch { /* not a blocker */ }
|
|
91
|
+
}
|
|
92
|
+
this.ensuredPermissions = true;
|
|
58
93
|
}
|
|
59
|
-
catch {
|
|
60
|
-
|
|
94
|
+
catch {
|
|
95
|
+
// Best-effort — never crash the plugin for a log setup failure.
|
|
96
|
+
this.ensuredPermissions = false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
seedCountersFromDisk() {
|
|
61
100
|
try {
|
|
62
|
-
const stat = fs.statSync(logPath);
|
|
101
|
+
const stat = fs.statSync(this.logPath);
|
|
63
102
|
this.approxSize = stat.size;
|
|
64
|
-
const content = fs.readFileSync(logPath, 'utf-8');
|
|
103
|
+
const content = fs.readFileSync(this.logPath, 'utf-8');
|
|
65
104
|
this.lineCount = content.split('\n').filter(l => l.length > 0).length;
|
|
66
105
|
}
|
|
67
106
|
catch {
|
|
@@ -69,9 +108,18 @@ class AuditLog {
|
|
|
69
108
|
this.approxSize = 0;
|
|
70
109
|
}
|
|
71
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Append a record. `filePath` may be empty — in that case we record the
|
|
113
|
+
* `<in-memory>` sentinel so the log doesn't emit an ambiguous trailing tab.
|
|
114
|
+
*/
|
|
72
115
|
append(sha256, filePath) {
|
|
73
|
-
const
|
|
116
|
+
const displayPath = filePath && filePath.length > 0 ? filePath : '<in-memory>';
|
|
117
|
+
const line = `${new Date().toISOString()}\t${sha256}\t${displayPath}\n`;
|
|
74
118
|
try {
|
|
119
|
+
if (!this.ensuredPermissions) {
|
|
120
|
+
// Container or file didn't exist at construction — retry now.
|
|
121
|
+
this.ensureContainerAndFile();
|
|
122
|
+
}
|
|
75
123
|
fs.appendFileSync(this.logPath, line);
|
|
76
124
|
this.lineCount++;
|
|
77
125
|
this.approxSize += Buffer.byteLength(line);
|
|
@@ -85,9 +133,7 @@ class AuditLog {
|
|
|
85
133
|
try {
|
|
86
134
|
const content = fs.readFileSync(this.logPath, 'utf-8');
|
|
87
135
|
const lines = content.split('\n').filter(l => l.length > 0);
|
|
88
|
-
// Keep at most half of maxLines
|
|
89
136
|
let keep = lines.slice(-Math.floor(this.maxLines / 2));
|
|
90
|
-
// Also trim by size: drop oldest lines until under half of maxBytes
|
|
91
137
|
const halfBytes = Math.floor(this.maxBytes / 2);
|
|
92
138
|
while (keep.length > 1) {
|
|
93
139
|
const size = Buffer.byteLength(keep.join('\n') + '\n');
|
|
@@ -96,7 +142,14 @@ class AuditLog {
|
|
|
96
142
|
keep.shift();
|
|
97
143
|
}
|
|
98
144
|
const newContent = keep.join('\n') + '\n';
|
|
99
|
-
|
|
145
|
+
// Write with explicit owner-only mode — some Node versions reset
|
|
146
|
+
// the file's mode on writeFileSync with content, so set it again
|
|
147
|
+
// on the original descriptor path.
|
|
148
|
+
fs.writeFileSync(this.logPath, newContent, { mode: 0o600 });
|
|
149
|
+
try {
|
|
150
|
+
fs.chmodSync(this.logPath, 0o600);
|
|
151
|
+
}
|
|
152
|
+
catch { /* ignore */ }
|
|
100
153
|
this.lineCount = keep.length;
|
|
101
154
|
this.approxSize = Buffer.byteLength(newContent);
|
|
102
155
|
}
|
|
@@ -105,8 +158,16 @@ class AuditLog {
|
|
|
105
158
|
}
|
|
106
159
|
exports.AuditLog = AuditLog;
|
|
107
160
|
/**
|
|
108
|
-
* Returns the directory for VT Sentinel logs
|
|
161
|
+
* Returns the directory for VT Sentinel audit logs.
|
|
162
|
+
*
|
|
163
|
+
* v0.12.0 change: logs now live in a dedicated `<stateDir>/vt-sentinel-audit/`
|
|
164
|
+
* subdirectory (pre-0.12 they were at the stateDir root). The subdir is
|
|
165
|
+
* created at 0o700 on first write.
|
|
166
|
+
*
|
|
167
|
+
* The stateDir must be supplied by the caller (previously fell back to reading
|
|
168
|
+
* the environment; that read has been removed to keep this module free of
|
|
169
|
+
* `process.env` lookups that could co-occur with HTTP clients elsewhere).
|
|
109
170
|
*/
|
|
110
|
-
function getLogDir() {
|
|
111
|
-
return
|
|
171
|
+
function getLogDir(stateDir) {
|
|
172
|
+
return path.join(stateDir, 'vt-sentinel-audit');
|
|
112
173
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compliance snapshot — the single source of truth that describes, in
|
|
3
|
+
* structured form, what VT Sentinel reads, writes, sends, and where it keeps
|
|
4
|
+
* its state.
|
|
5
|
+
*
|
|
6
|
+
* Consumed by three call sites:
|
|
7
|
+
* 1. `registerSecurityAuditCollector` — maps the snapshot to
|
|
8
|
+
* `SecurityAuditFinding[]` so `openclaw security audit --deep` can
|
|
9
|
+
* display it.
|
|
10
|
+
* 2. `vt_sentinel_status` — renders a "Compliance / Data Flow" block from
|
|
11
|
+
* the same data, so end users see the same picture the audit does.
|
|
12
|
+
* 3. Tests — assert the snapshot shape against mock configurations.
|
|
13
|
+
*
|
|
14
|
+
* This module is a pure function with no module-level side effects. Optional
|
|
15
|
+
* file-mode info can be collected by the caller via `collectLogModes()` and
|
|
16
|
+
* passed in; when absent, the snapshot simply omits permission checks.
|
|
17
|
+
*/
|
|
18
|
+
import type { FullConfig, BlockMode, NotifyLevel, PresetName, AgentMetadataMode } from './config-manager';
|
|
19
|
+
import type { SensitiveFilePolicy } from './scanner';
|
|
20
|
+
export type CredentialMode = 'user_key' | 'vtai' | 'none';
|
|
21
|
+
export interface LogFileMode {
|
|
22
|
+
path: string;
|
|
23
|
+
exists: boolean;
|
|
24
|
+
mode?: string;
|
|
25
|
+
ownerPrivate: boolean;
|
|
26
|
+
}
|
|
27
|
+
export interface LogModesInfo {
|
|
28
|
+
auditDir: LogFileMode;
|
|
29
|
+
uploadsLog: LogFileMode;
|
|
30
|
+
detectionsLog: LogFileMode;
|
|
31
|
+
credentialsFile: LogFileMode;
|
|
32
|
+
stateFile: LogFileMode;
|
|
33
|
+
}
|
|
34
|
+
export interface ComplianceSnapshotInput {
|
|
35
|
+
config: FullConfig;
|
|
36
|
+
stateDir: string;
|
|
37
|
+
credentialMode: CredentialMode;
|
|
38
|
+
watchDirs: string[];
|
|
39
|
+
agentPublicHandle?: string;
|
|
40
|
+
/** Optional: pre-collected log permission info (see `collectLogModes`). */
|
|
41
|
+
logModes?: LogModesInfo;
|
|
42
|
+
}
|
|
43
|
+
export interface ComplianceSnapshot {
|
|
44
|
+
credentialMode: CredentialMode;
|
|
45
|
+
endpoints: {
|
|
46
|
+
virustotal: boolean;
|
|
47
|
+
virustotalAi: boolean;
|
|
48
|
+
/** npm registry only consulted from the explicit vt_sentinel_update tool */
|
|
49
|
+
npm: 'on-demand-only';
|
|
50
|
+
/** ClawHub only consulted from the explicit vt_sentinel_update tool */
|
|
51
|
+
clawhub: 'on-demand-only';
|
|
52
|
+
};
|
|
53
|
+
policies: {
|
|
54
|
+
autoScan: boolean;
|
|
55
|
+
sensitiveFilePolicy: SensitiveFilePolicy;
|
|
56
|
+
semanticFilePolicy: SensitiveFilePolicy;
|
|
57
|
+
blockMode: BlockMode;
|
|
58
|
+
notifyLevel: NotifyLevel;
|
|
59
|
+
preset: PresetName;
|
|
60
|
+
maxFileSizeMb: number;
|
|
61
|
+
showCleanScanLogs: boolean;
|
|
62
|
+
};
|
|
63
|
+
watchDirs: string[];
|
|
64
|
+
excludeDirs: string[];
|
|
65
|
+
excludeGlobs: string[];
|
|
66
|
+
paths: {
|
|
67
|
+
stateDir: string;
|
|
68
|
+
credentialsFile: string;
|
|
69
|
+
stateFile: string;
|
|
70
|
+
auditDir: string;
|
|
71
|
+
uploadsLog: string;
|
|
72
|
+
detectionsLog: string;
|
|
73
|
+
};
|
|
74
|
+
identity: {
|
|
75
|
+
/** display name declared to VTAI (either user-set or auto-generated) */
|
|
76
|
+
displayNameSet: boolean;
|
|
77
|
+
humanAliasSet: boolean;
|
|
78
|
+
bioSet: boolean;
|
|
79
|
+
contactEmailSet: boolean;
|
|
80
|
+
metadataMode: AgentMetadataMode;
|
|
81
|
+
publicHandle?: string;
|
|
82
|
+
};
|
|
83
|
+
logModes?: LogModesInfo;
|
|
84
|
+
baseline: AuditFinding[];
|
|
85
|
+
risks: AuditFinding[];
|
|
86
|
+
}
|
|
87
|
+
export interface AuditFinding {
|
|
88
|
+
checkId: string;
|
|
89
|
+
severity: 'info' | 'warn' | 'critical';
|
|
90
|
+
title: string;
|
|
91
|
+
detail: string;
|
|
92
|
+
remediation?: string;
|
|
93
|
+
}
|
|
94
|
+
export declare function computePaths(stateDir: string): ComplianceSnapshot['paths'];
|
|
95
|
+
/**
|
|
96
|
+
* Stat the log files + state files + audit dir to record their permissions.
|
|
97
|
+
* Safe to call even if none of them exist yet (returns `exists: false`).
|
|
98
|
+
*/
|
|
99
|
+
export declare function collectLogModes(stateDir: string): LogModesInfo;
|
|
100
|
+
/**
|
|
101
|
+
* Build the compliance snapshot. Pure function — no I/O.
|
|
102
|
+
* Callers that want log-permission info should call `collectLogModes(stateDir)`
|
|
103
|
+
* first and pass the result as `input.logModes`.
|
|
104
|
+
*/
|
|
105
|
+
export declare function buildComplianceSnapshot(input: ComplianceSnapshotInput): ComplianceSnapshot;
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Compliance snapshot — the single source of truth that describes, in
|
|
4
|
+
* structured form, what VT Sentinel reads, writes, sends, and where it keeps
|
|
5
|
+
* its state.
|
|
6
|
+
*
|
|
7
|
+
* Consumed by three call sites:
|
|
8
|
+
* 1. `registerSecurityAuditCollector` — maps the snapshot to
|
|
9
|
+
* `SecurityAuditFinding[]` so `openclaw security audit --deep` can
|
|
10
|
+
* display it.
|
|
11
|
+
* 2. `vt_sentinel_status` — renders a "Compliance / Data Flow" block from
|
|
12
|
+
* the same data, so end users see the same picture the audit does.
|
|
13
|
+
* 3. Tests — assert the snapshot shape against mock configurations.
|
|
14
|
+
*
|
|
15
|
+
* This module is a pure function with no module-level side effects. Optional
|
|
16
|
+
* file-mode info can be collected by the caller via `collectLogModes()` and
|
|
17
|
+
* passed in; when absent, the snapshot simply omits permission checks.
|
|
18
|
+
*/
|
|
19
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
20
|
+
if (k2 === undefined) k2 = k;
|
|
21
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
22
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
23
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
24
|
+
}
|
|
25
|
+
Object.defineProperty(o, k2, desc);
|
|
26
|
+
}) : (function(o, m, k, k2) {
|
|
27
|
+
if (k2 === undefined) k2 = k;
|
|
28
|
+
o[k2] = m[k];
|
|
29
|
+
}));
|
|
30
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
31
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
32
|
+
}) : function(o, v) {
|
|
33
|
+
o["default"] = v;
|
|
34
|
+
});
|
|
35
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
36
|
+
var ownKeys = function(o) {
|
|
37
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
38
|
+
var ar = [];
|
|
39
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
40
|
+
return ar;
|
|
41
|
+
};
|
|
42
|
+
return ownKeys(o);
|
|
43
|
+
};
|
|
44
|
+
return function (mod) {
|
|
45
|
+
if (mod && mod.__esModule) return mod;
|
|
46
|
+
var result = {};
|
|
47
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
48
|
+
__setModuleDefault(result, mod);
|
|
49
|
+
return result;
|
|
50
|
+
};
|
|
51
|
+
})();
|
|
52
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
53
|
+
exports.computePaths = computePaths;
|
|
54
|
+
exports.collectLogModes = collectLogModes;
|
|
55
|
+
exports.buildComplianceSnapshot = buildComplianceSnapshot;
|
|
56
|
+
const fs = __importStar(require("fs"));
|
|
57
|
+
const path = __importStar(require("path"));
|
|
58
|
+
// --- Core paths helper (pure) ---
|
|
59
|
+
function computePaths(stateDir) {
|
|
60
|
+
const auditDir = path.join(stateDir, 'vt-sentinel-audit');
|
|
61
|
+
return {
|
|
62
|
+
stateDir,
|
|
63
|
+
credentialsFile: path.join(stateDir, 'vt-sentinel-agent.json'),
|
|
64
|
+
stateFile: path.join(stateDir, 'vt-sentinel-state.json'),
|
|
65
|
+
auditDir,
|
|
66
|
+
uploadsLog: path.join(auditDir, 'uploads.log'),
|
|
67
|
+
detectionsLog: path.join(auditDir, 'detections.log'),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
// --- Log-mode collector (optional, I/O) ---
|
|
71
|
+
/**
|
|
72
|
+
* Stat the log files + state files + audit dir to record their permissions.
|
|
73
|
+
* Safe to call even if none of them exist yet (returns `exists: false`).
|
|
74
|
+
*/
|
|
75
|
+
function collectLogModes(stateDir) {
|
|
76
|
+
const p = computePaths(stateDir);
|
|
77
|
+
return {
|
|
78
|
+
auditDir: statMode(p.auditDir, true),
|
|
79
|
+
uploadsLog: statMode(p.uploadsLog, false),
|
|
80
|
+
detectionsLog: statMode(p.detectionsLog, false),
|
|
81
|
+
credentialsFile: statMode(p.credentialsFile, false),
|
|
82
|
+
stateFile: statMode(p.stateFile, false),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function statMode(fsPath, isDir) {
|
|
86
|
+
try {
|
|
87
|
+
const st = fs.statSync(fsPath);
|
|
88
|
+
const modeBits = st.mode & 0o777;
|
|
89
|
+
const mode = modeBits.toString(8).padStart(isDir ? 3 : 3, '0');
|
|
90
|
+
// Owner-private means: no group-read AND no other-read
|
|
91
|
+
// For dirs we also want no group-exec/other-exec.
|
|
92
|
+
const ownerPrivate = (modeBits & 0o077) === 0;
|
|
93
|
+
return { path: fsPath, exists: true, mode, ownerPrivate };
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// Missing file / dir is fine — the plugin creates it lazily on first
|
|
97
|
+
// write with {mode: 0o600}/{mode: 0o700} from v0.12.0 onwards, so a
|
|
98
|
+
// non-existent log is implicitly "private once it exists".
|
|
99
|
+
return { path: fsPath, exists: false, ownerPrivate: true };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// --- Risk detection (pure helpers) ---
|
|
103
|
+
/** A watch dir is "risky" if it's close to the filesystem root or a whole user profile. */
|
|
104
|
+
function isBroadWatchDir(dir) {
|
|
105
|
+
if (!dir)
|
|
106
|
+
return false;
|
|
107
|
+
// Root '/' must be handled before trimming the trailing separator.
|
|
108
|
+
if (dir === '/' || /^[A-Za-z]:[\\/]?$/.test(dir))
|
|
109
|
+
return true;
|
|
110
|
+
const norm = dir.replace(/[\\/]+$/, ''); // trim trailing separator
|
|
111
|
+
// Unix near-root
|
|
112
|
+
if (['/home', '/Users'].includes(norm))
|
|
113
|
+
return true;
|
|
114
|
+
// Exact $HOME (common mistake — scans user profile including dotfiles)
|
|
115
|
+
// We can't resolve $HOME here without I/O; instead treat paths that end
|
|
116
|
+
// with just the user directory segment as broad.
|
|
117
|
+
// Heuristic: directory depth 2 or less (e.g. /Users/foo, /home/foo, /root).
|
|
118
|
+
const parts = norm.split(/[\\/]/).filter(Boolean);
|
|
119
|
+
if (parts.length <= 2 && /^(Users|home|root)$/.test(parts[0] ?? ''))
|
|
120
|
+
return true;
|
|
121
|
+
// Windows user dir at depth 2 (e.g. C:\Users\foo)
|
|
122
|
+
if (parts.length === 3 && /^[A-Za-z]:$/.test(parts[0] ?? '') && parts[1]?.toLowerCase() === 'users')
|
|
123
|
+
return true;
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
// --- Main builder ---
|
|
127
|
+
/**
|
|
128
|
+
* Build the compliance snapshot. Pure function — no I/O.
|
|
129
|
+
* Callers that want log-permission info should call `collectLogModes(stateDir)`
|
|
130
|
+
* first and pass the result as `input.logModes`.
|
|
131
|
+
*/
|
|
132
|
+
function buildComplianceSnapshot(input) {
|
|
133
|
+
const { config, stateDir, credentialMode, watchDirs, agentPublicHandle, logModes } = input;
|
|
134
|
+
const paths = computePaths(stateDir);
|
|
135
|
+
const identity = {
|
|
136
|
+
displayNameSet: !!config.agentDisplayName,
|
|
137
|
+
humanAliasSet: !!config.agentHumanAlias,
|
|
138
|
+
bioSet: !!config.agentBio,
|
|
139
|
+
contactEmailSet: !!config.agentContactEmail,
|
|
140
|
+
metadataMode: config.agentMetadataMode ?? 'minimal',
|
|
141
|
+
publicHandle: agentPublicHandle,
|
|
142
|
+
};
|
|
143
|
+
const baseline = [];
|
|
144
|
+
const risks = [];
|
|
145
|
+
// --- Baseline info findings (what the plugin does) ---
|
|
146
|
+
baseline.push({
|
|
147
|
+
checkId: 'vt-sentinel.credential-mode',
|
|
148
|
+
severity: 'info',
|
|
149
|
+
title: `Credential mode: ${credentialMode}`,
|
|
150
|
+
detail: credentialMode === 'user_key'
|
|
151
|
+
? 'Using a user-provided VirusTotal API key from plugin config. All scans go to www.virustotal.com.'
|
|
152
|
+
: credentialMode === 'vtai'
|
|
153
|
+
? 'Using an auto-registered VTAI agent token (zero-config). All scans go to ai.virustotal.com.'
|
|
154
|
+
: 'No credentials active. Scanner disabled until a config apiKey is set or VTAI auto-registration completes.',
|
|
155
|
+
});
|
|
156
|
+
baseline.push({
|
|
157
|
+
checkId: 'vt-sentinel.endpoints',
|
|
158
|
+
severity: 'info',
|
|
159
|
+
title: 'Network endpoints contacted',
|
|
160
|
+
detail: (credentialMode === 'user_key' ? 'www.virustotal.com (scan requests)\n' : '') +
|
|
161
|
+
(credentialMode === 'vtai' ? 'ai.virustotal.com (scan requests)\n' : '') +
|
|
162
|
+
'registry.npmjs.org + clawhub.ai — only when the user explicitly invokes vt_sentinel_update.',
|
|
163
|
+
});
|
|
164
|
+
baseline.push({
|
|
165
|
+
checkId: 'vt-sentinel.auto-scan',
|
|
166
|
+
severity: 'info',
|
|
167
|
+
title: `Auto-scan: ${config.autoScan ? 'enabled' : 'disabled'}`,
|
|
168
|
+
detail: config.autoScan
|
|
169
|
+
? `Watching ${watchDirs.length} director${watchDirs.length === 1 ? 'y' : 'ies'}. block mode: ${config.blockMode}. notify level: ${config.notifyLevel}.`
|
|
170
|
+
: `Active protection hooks remain registered (block mode: ${config.blockMode}) but no background watcher is running.`,
|
|
171
|
+
});
|
|
172
|
+
baseline.push({
|
|
173
|
+
checkId: 'vt-sentinel.upload-policies',
|
|
174
|
+
severity: 'info',
|
|
175
|
+
title: 'Upload policies',
|
|
176
|
+
detail: `sensitive (PDF/Office/unknown archives): ${config.sensitiveFilePolicy}\n` +
|
|
177
|
+
`semantic (SKILL.md, HOOK.md, AGENTS.md, etc.): ${config.semanticFilePolicy}\n` +
|
|
178
|
+
`preset: ${config.configPreset}, maxFileSizeMb: ${config.maxFileSizeMb}`,
|
|
179
|
+
});
|
|
180
|
+
baseline.push({
|
|
181
|
+
checkId: 'vt-sentinel.state-paths',
|
|
182
|
+
severity: 'info',
|
|
183
|
+
title: 'State and log file paths',
|
|
184
|
+
detail: `credentials: ${paths.credentialsFile} (0o600)\n` +
|
|
185
|
+
`runtime state: ${paths.stateFile}\n` +
|
|
186
|
+
`audit logs: ${paths.auditDir} (uploads.log, detections.log, target 0o600)`,
|
|
187
|
+
});
|
|
188
|
+
baseline.push({
|
|
189
|
+
checkId: 'vt-sentinel.identity-metadata',
|
|
190
|
+
severity: 'info',
|
|
191
|
+
title: `VTAI identity metadata: ${identity.metadataMode}`,
|
|
192
|
+
detail: `displayName set: ${identity.displayNameSet}\n` +
|
|
193
|
+
`humanAlias set: ${identity.humanAliasSet}\n` +
|
|
194
|
+
`bio set: ${identity.bioSet}\n` +
|
|
195
|
+
`contactEmail set: ${identity.contactEmailSet}` +
|
|
196
|
+
(identity.publicHandle ? `\npublic handle: ${identity.publicHandle}` : ''),
|
|
197
|
+
});
|
|
198
|
+
// --- Risk flags (warnings) ---
|
|
199
|
+
if (credentialMode === 'none') {
|
|
200
|
+
risks.push({
|
|
201
|
+
checkId: 'vt-sentinel.no-credentials',
|
|
202
|
+
severity: 'warn',
|
|
203
|
+
title: 'No active VirusTotal credentials',
|
|
204
|
+
detail: 'The plugin is loaded but cannot run scans until credentials are configured or VTAI auto-registration succeeds.',
|
|
205
|
+
remediation: 'Either invoke any scan tool to trigger VTAI auto-registration, or set plugins.entries.openclaw-plugin-vt-sentinel.config.apiKey via `openclaw config set`.',
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
if (config.sensitiveFilePolicy === 'always_upload') {
|
|
209
|
+
risks.push({
|
|
210
|
+
checkId: 'vt-sentinel.always-upload-sensitive',
|
|
211
|
+
severity: 'warn',
|
|
212
|
+
title: 'Sensitive files auto-uploaded without consent',
|
|
213
|
+
detail: 'sensitiveFilePolicy=always_upload means PDFs, Office docs, and unknown archives are sent to VirusTotal with no per-file prompt.',
|
|
214
|
+
remediation: 'Switch to `ask`, `ask_once`, or `hash_only` via vt_sentinel_configure.',
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
if (config.semanticFilePolicy === 'always_upload') {
|
|
218
|
+
risks.push({
|
|
219
|
+
checkId: 'vt-sentinel.always-upload-semantic',
|
|
220
|
+
severity: 'warn',
|
|
221
|
+
title: 'Instruction files auto-uploaded without consent',
|
|
222
|
+
detail: 'semanticFilePolicy=always_upload means SKILL.md, HOOK.md, AGENTS.md, etc. are sent to VirusTotal with no per-file prompt. These often contain private operational data.',
|
|
223
|
+
remediation: 'Switch to `hash_only` (recommended default) or `ask` via vt_sentinel_configure.',
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
const broadDirs = watchDirs.filter(isBroadWatchDir);
|
|
227
|
+
if (broadDirs.length > 0) {
|
|
228
|
+
risks.push({
|
|
229
|
+
checkId: 'vt-sentinel.broad-watch-dirs',
|
|
230
|
+
severity: 'warn',
|
|
231
|
+
title: 'Very broad directory under watch',
|
|
232
|
+
detail: `Watching: ${broadDirs.join(', ')}. A root-level or whole-profile watcher can generate noisy scan traffic and increases the blast radius of upload policies.`,
|
|
233
|
+
remediation: 'Narrow watchDirs to specific project / download folders via vt_sentinel_configure.',
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
if (config.agentContactEmail) {
|
|
237
|
+
risks.push({
|
|
238
|
+
checkId: 'vt-sentinel.contact-email-shared',
|
|
239
|
+
severity: 'warn',
|
|
240
|
+
title: 'Contact email shared with VTAI',
|
|
241
|
+
detail: 'agentContactEmail is set, so the address is sent to VTAI on agent registration. This is your choice but worth surfacing explicitly.',
|
|
242
|
+
remediation: 'Remove agentContactEmail from config if you prefer no PII in the VTAI registration.',
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
if (!config.autoScan && config.blockMode === 'quarantine') {
|
|
246
|
+
risks.push({
|
|
247
|
+
checkId: 'vt-sentinel.passive-with-quarantine',
|
|
248
|
+
severity: 'warn',
|
|
249
|
+
title: 'Quarantine mode active without auto-scan',
|
|
250
|
+
detail: 'blockMode=quarantine will isolate files matched by the blocklist, but autoScan=false means new files are not proactively scanned. Threats not yet in the blocklist will not be caught.',
|
|
251
|
+
remediation: 'Either enable autoScan or switch blockMode to `log_only`/`block_only` to align the posture.',
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
if (logModes) {
|
|
255
|
+
const nonPrivate = [];
|
|
256
|
+
if (logModes.credentialsFile.exists && !logModes.credentialsFile.ownerPrivate)
|
|
257
|
+
nonPrivate.push(`credentials (${logModes.credentialsFile.mode})`);
|
|
258
|
+
if (logModes.stateFile.exists && !logModes.stateFile.ownerPrivate)
|
|
259
|
+
nonPrivate.push(`state (${logModes.stateFile.mode})`);
|
|
260
|
+
if (logModes.uploadsLog.exists && !logModes.uploadsLog.ownerPrivate)
|
|
261
|
+
nonPrivate.push(`uploads.log (${logModes.uploadsLog.mode})`);
|
|
262
|
+
if (logModes.detectionsLog.exists && !logModes.detectionsLog.ownerPrivate)
|
|
263
|
+
nonPrivate.push(`detections.log (${logModes.detectionsLog.mode})`);
|
|
264
|
+
if (logModes.auditDir.exists && !logModes.auditDir.ownerPrivate)
|
|
265
|
+
nonPrivate.push(`audit dir (${logModes.auditDir.mode})`);
|
|
266
|
+
if (nonPrivate.length > 0) {
|
|
267
|
+
risks.push({
|
|
268
|
+
checkId: 'vt-sentinel.logs-not-private',
|
|
269
|
+
severity: 'warn',
|
|
270
|
+
title: 'State or log files are not owner-private',
|
|
271
|
+
detail: `The following files permit group/other read: ${nonPrivate.join(', ')}. Credentials and detection history should be 0o600 (files) / 0o700 (dirs).`,
|
|
272
|
+
remediation: 'On POSIX, run `chmod 600` on the files and `chmod 700` on the audit dir. New files created by this plugin (v0.12.0+) are already 0o600 on creation.',
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return {
|
|
277
|
+
credentialMode,
|
|
278
|
+
endpoints: {
|
|
279
|
+
virustotal: credentialMode === 'user_key',
|
|
280
|
+
virustotalAi: credentialMode === 'vtai',
|
|
281
|
+
npm: 'on-demand-only',
|
|
282
|
+
clawhub: 'on-demand-only',
|
|
283
|
+
},
|
|
284
|
+
policies: {
|
|
285
|
+
autoScan: config.autoScan,
|
|
286
|
+
sensitiveFilePolicy: config.sensitiveFilePolicy,
|
|
287
|
+
semanticFilePolicy: config.semanticFilePolicy,
|
|
288
|
+
blockMode: config.blockMode,
|
|
289
|
+
notifyLevel: config.notifyLevel,
|
|
290
|
+
preset: config.configPreset,
|
|
291
|
+
maxFileSizeMb: config.maxFileSizeMb,
|
|
292
|
+
showCleanScanLogs: config.showCleanScanLogs,
|
|
293
|
+
},
|
|
294
|
+
watchDirs: [...watchDirs],
|
|
295
|
+
excludeDirs: [...(config.excludeDirs ?? [])],
|
|
296
|
+
excludeGlobs: [...(config.excludeGlobs ?? [])],
|
|
297
|
+
paths,
|
|
298
|
+
identity,
|
|
299
|
+
logModes,
|
|
300
|
+
baseline,
|
|
301
|
+
risks,
|
|
302
|
+
};
|
|
303
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -40,6 +40,25 @@ interface PluginApi {
|
|
|
40
40
|
}) => void;
|
|
41
41
|
registerHook?: (events: string | string[], handler: (event: any) => Promise<any>, opts?: object) => void;
|
|
42
42
|
onToolResult?: (handler: (event: any) => Promise<any>) => void;
|
|
43
|
+
registerSecurityAuditCollector?: (collector: (ctx: {
|
|
44
|
+
config: any;
|
|
45
|
+
sourceConfig: any;
|
|
46
|
+
env: NodeJS.ProcessEnv;
|
|
47
|
+
stateDir: string;
|
|
48
|
+
configPath: string;
|
|
49
|
+
}) => Array<{
|
|
50
|
+
checkId: string;
|
|
51
|
+
severity: 'info' | 'warn' | 'critical';
|
|
52
|
+
title: string;
|
|
53
|
+
detail: string;
|
|
54
|
+
remediation?: string;
|
|
55
|
+
}> | Promise<Array<{
|
|
56
|
+
checkId: string;
|
|
57
|
+
severity: 'info' | 'warn' | 'critical';
|
|
58
|
+
title: string;
|
|
59
|
+
detail: string;
|
|
60
|
+
remediation?: string;
|
|
61
|
+
}>>) => void;
|
|
43
62
|
}
|
|
44
63
|
/**
|
|
45
64
|
* Simple semver comparison: returns true if `latest` is newer than `current`.
|
package/dist/index.js
CHANGED
|
@@ -52,6 +52,7 @@ const vt_api_1 = require("./vt-api");
|
|
|
52
52
|
const env_access_1 = require("./env-access");
|
|
53
53
|
const version_1 = require("./version");
|
|
54
54
|
const update_commands_1 = require("./update-commands");
|
|
55
|
+
const compliance_snapshot_1 = require("./compliance-snapshot");
|
|
55
56
|
const audit_log_1 = require("./audit-log");
|
|
56
57
|
const config_manager_1 = require("./config-manager");
|
|
57
58
|
const state_store_1 = require("./state-store");
|
|
@@ -354,9 +355,11 @@ function vtSentinelPlugin(api) {
|
|
|
354
355
|
return true;
|
|
355
356
|
};
|
|
356
357
|
// --- Audit logs: rotating logs for uploads and detections ---
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
const
|
|
358
|
+
// v0.12.0: logs live in <stateDir>/vt-sentinel-audit/ (subdir at 0o700,
|
|
359
|
+
// files pre-created at 0o600 — see src/audit-log.ts for the hardening).
|
|
360
|
+
const logDir = (0, audit_log_1.getLogDir)(resolvedStateDir);
|
|
361
|
+
const uploadLog = new audit_log_1.AuditLog(path.join(logDir, 'uploads.log'));
|
|
362
|
+
const detectionLog = new audit_log_1.AuditLog(path.join(logDir, 'detections.log'));
|
|
360
363
|
/** Log a scan result to the appropriate audit log(s). */
|
|
361
364
|
const auditResult = (result) => {
|
|
362
365
|
if (!result.sha256)
|
|
@@ -737,6 +740,50 @@ function vtSentinelPlugin(api) {
|
|
|
737
740
|
api.logger.info('[VT-Sentinel] Service stopped');
|
|
738
741
|
},
|
|
739
742
|
});
|
|
743
|
+
// --- Security audit collector (v0.12.0 transparency surface) ---
|
|
744
|
+
//
|
|
745
|
+
// Reports what VT Sentinel reads, writes, sends, and flags config that
|
|
746
|
+
// carries user-visible risk (auto-uploads, broad watchers, non-private
|
|
747
|
+
// state files, PII in VTAI identity, inconsistent block vs. scan posture).
|
|
748
|
+
// Surfaced via `openclaw security audit --deep --json`. All data is derived
|
|
749
|
+
// from the same `buildComplianceSnapshot(...)` helper that renders the
|
|
750
|
+
// Compliance block in `vt_sentinel_status`, so both views stay in sync.
|
|
751
|
+
if (typeof api.registerSecurityAuditCollector === 'function') {
|
|
752
|
+
api.registerSecurityAuditCollector((ctx) => {
|
|
753
|
+
try {
|
|
754
|
+
const mode = credentialMode === 'user_key' ? 'user_key' :
|
|
755
|
+
credentialMode === 'vtai' ? 'vtai' : 'none';
|
|
756
|
+
const eff = configManager.getEffective();
|
|
757
|
+
// Use the audit context's stateDir when provided (it already
|
|
758
|
+
// honors profile overrides); fall back to our resolvedStateDir.
|
|
759
|
+
const sd = ctx.stateDir || resolvedStateDir;
|
|
760
|
+
const logModes = (0, compliance_snapshot_1.collectLogModes)(sd);
|
|
761
|
+
const snap = (0, compliance_snapshot_1.buildComplianceSnapshot)({
|
|
762
|
+
config: eff,
|
|
763
|
+
stateDir: sd,
|
|
764
|
+
credentialMode: mode,
|
|
765
|
+
watchDirs: [...watchRoots],
|
|
766
|
+
agentPublicHandle: (0, vt_api_1.loadAgentCredentials)()?.publicHandle,
|
|
767
|
+
logModes,
|
|
768
|
+
});
|
|
769
|
+
return [...snap.baseline, ...snap.risks].map(f => ({
|
|
770
|
+
checkId: f.checkId,
|
|
771
|
+
severity: f.severity,
|
|
772
|
+
title: f.title,
|
|
773
|
+
detail: f.detail,
|
|
774
|
+
...(f.remediation ? { remediation: f.remediation } : {}),
|
|
775
|
+
}));
|
|
776
|
+
}
|
|
777
|
+
catch (err) {
|
|
778
|
+
return [{
|
|
779
|
+
checkId: 'vt-sentinel.audit-collector-error',
|
|
780
|
+
severity: 'warn',
|
|
781
|
+
title: 'VT Sentinel compliance snapshot failed to build',
|
|
782
|
+
detail: `Collector threw while assembling audit findings: ${err?.message || String(err)}`,
|
|
783
|
+
}];
|
|
784
|
+
}
|
|
785
|
+
});
|
|
786
|
+
}
|
|
740
787
|
// --- Tool: vt_scan_file ---
|
|
741
788
|
api.registerTool({
|
|
742
789
|
name: 'vt_scan_file',
|
|
@@ -901,6 +948,18 @@ function vtSentinelPlugin(api) {
|
|
|
901
948
|
parameters: { type: 'object', properties: {}, required: [] },
|
|
902
949
|
execute: async (_ctx, _params) => {
|
|
903
950
|
const eff = configManager.getEffective();
|
|
951
|
+
// Build the compliance snapshot from the same source used by the
|
|
952
|
+
// security audit collector, so the two views never drift.
|
|
953
|
+
const snapMode = credentialMode === 'user_key' ? 'user_key' :
|
|
954
|
+
credentialMode === 'vtai' ? 'vtai' : 'none';
|
|
955
|
+
const snap = (0, compliance_snapshot_1.buildComplianceSnapshot)({
|
|
956
|
+
config: eff,
|
|
957
|
+
stateDir: resolvedStateDir,
|
|
958
|
+
credentialMode: snapMode,
|
|
959
|
+
watchDirs: [...watchRoots],
|
|
960
|
+
agentPublicHandle: (0, vt_api_1.loadAgentCredentials)()?.publicHandle,
|
|
961
|
+
logModes: (0, compliance_snapshot_1.collectLogModes)(resolvedStateDir),
|
|
962
|
+
});
|
|
904
963
|
return textResponse((0, status_renderer_1.renderStatus)({
|
|
905
964
|
version: (0, version_1.getCurrentVersion)(),
|
|
906
965
|
apiMode: credentialMode === 'vtai' ? 'vtai' : 'user_key',
|
|
@@ -918,6 +977,23 @@ function vtSentinelPlugin(api) {
|
|
|
918
977
|
metadataMode: eff.agentMetadataMode || 'minimal',
|
|
919
978
|
humanAlias: eff.agentHumanAlias,
|
|
920
979
|
},
|
|
980
|
+
compliance: {
|
|
981
|
+
endpoints: snap.endpoints,
|
|
982
|
+
credentialMode: snap.credentialMode,
|
|
983
|
+
paths: {
|
|
984
|
+
credentialsFile: snap.paths.credentialsFile,
|
|
985
|
+
stateFile: snap.paths.stateFile,
|
|
986
|
+
auditDir: snap.paths.auditDir,
|
|
987
|
+
},
|
|
988
|
+
identity: {
|
|
989
|
+
displayNameSet: snap.identity.displayNameSet,
|
|
990
|
+
humanAliasSet: snap.identity.humanAliasSet,
|
|
991
|
+
bioSet: snap.identity.bioSet,
|
|
992
|
+
contactEmailSet: snap.identity.contactEmailSet,
|
|
993
|
+
metadataMode: snap.identity.metadataMode,
|
|
994
|
+
},
|
|
995
|
+
risks: snap.risks,
|
|
996
|
+
},
|
|
921
997
|
}));
|
|
922
998
|
},
|
|
923
999
|
});
|
|
@@ -6,6 +6,32 @@ export declare function renderOnboarding(opts: {
|
|
|
6
6
|
effectiveConfig: FullConfig;
|
|
7
7
|
availableTools: string[];
|
|
8
8
|
}): string;
|
|
9
|
+
export interface ComplianceRenderBlock {
|
|
10
|
+
endpoints: {
|
|
11
|
+
virustotal: boolean;
|
|
12
|
+
virustotalAi: boolean;
|
|
13
|
+
};
|
|
14
|
+
credentialMode: 'user_key' | 'vtai' | 'none';
|
|
15
|
+
paths: {
|
|
16
|
+
credentialsFile: string;
|
|
17
|
+
stateFile: string;
|
|
18
|
+
auditDir: string;
|
|
19
|
+
};
|
|
20
|
+
identity: {
|
|
21
|
+
displayNameSet: boolean;
|
|
22
|
+
humanAliasSet: boolean;
|
|
23
|
+
bioSet: boolean;
|
|
24
|
+
contactEmailSet: boolean;
|
|
25
|
+
metadataMode: string;
|
|
26
|
+
};
|
|
27
|
+
risks: Array<{
|
|
28
|
+
checkId: string;
|
|
29
|
+
severity: string;
|
|
30
|
+
title: string;
|
|
31
|
+
detail: string;
|
|
32
|
+
remediation?: string;
|
|
33
|
+
}>;
|
|
34
|
+
}
|
|
9
35
|
export declare function renderStatus(opts: {
|
|
10
36
|
version: string;
|
|
11
37
|
apiMode: 'user_key' | 'vtai';
|
|
@@ -23,6 +49,7 @@ export declare function renderStatus(opts: {
|
|
|
23
49
|
metadataMode: string;
|
|
24
50
|
humanAlias?: string;
|
|
25
51
|
};
|
|
52
|
+
compliance?: ComplianceRenderBlock;
|
|
26
53
|
}): string;
|
|
27
54
|
export declare function renderPolicyMatrix(config: FullConfig): string;
|
|
28
55
|
export declare function renderHelp(): string;
|
package/dist/status-renderer.js
CHANGED
|
@@ -33,7 +33,6 @@ function renderOnboarding(opts) {
|
|
|
33
33
|
lines.push('Run vt_sentinel_status for full status. Run vt_sentinel_help for usage guide.');
|
|
34
34
|
return lines.join('\n');
|
|
35
35
|
}
|
|
36
|
-
// --- Status ---
|
|
37
36
|
function renderStatus(opts) {
|
|
38
37
|
const lines = [];
|
|
39
38
|
const cfg = opts.effectiveConfig;
|
|
@@ -95,6 +94,39 @@ function renderStatus(opts) {
|
|
|
95
94
|
lines.push('Runtime State:');
|
|
96
95
|
lines.push(` Blocked files: ${opts.blockedFileCount}`);
|
|
97
96
|
lines.push('');
|
|
97
|
+
// Compliance / Data Flow (v0.12.0+)
|
|
98
|
+
if (opts.compliance) {
|
|
99
|
+
const c = opts.compliance;
|
|
100
|
+
lines.push('Compliance / Data Flow:');
|
|
101
|
+
const endpoints = [];
|
|
102
|
+
if (c.endpoints.virustotal)
|
|
103
|
+
endpoints.push('www.virustotal.com');
|
|
104
|
+
if (c.endpoints.virustotalAi)
|
|
105
|
+
endpoints.push('ai.virustotal.com');
|
|
106
|
+
lines.push(` Network (live): ${endpoints.length ? endpoints.join(', ') : '(none — scanner not configured)'}`);
|
|
107
|
+
lines.push(` Network (on-demand only): registry.npmjs.org, clawhub.ai (via vt_sentinel_update)`);
|
|
108
|
+
lines.push(` Credential mode: ${c.credentialMode}`);
|
|
109
|
+
lines.push(` Credentials file: ${c.paths.credentialsFile} (target 0o600)`);
|
|
110
|
+
lines.push(` Runtime state file: ${c.paths.stateFile}`);
|
|
111
|
+
lines.push(` Audit logs dir: ${c.paths.auditDir} (target 0o700; uploads.log + detections.log at 0o600)`);
|
|
112
|
+
lines.push(` VTAI identity metadata sent: ${c.identity.metadataMode}` +
|
|
113
|
+
` (displayName=${c.identity.displayNameSet ? 'set' : 'none'},` +
|
|
114
|
+
` humanAlias=${c.identity.humanAliasSet ? 'set' : 'none'},` +
|
|
115
|
+
` bio=${c.identity.bioSet ? 'set' : 'none'},` +
|
|
116
|
+
` email=${c.identity.contactEmailSet ? 'set' : 'none'})`);
|
|
117
|
+
if (c.risks.length === 0) {
|
|
118
|
+
lines.push(' Risk flags: none');
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
lines.push(` Risk flags (${c.risks.length}):`);
|
|
122
|
+
for (const r of c.risks) {
|
|
123
|
+
lines.push(` [${r.severity}] ${r.title}`);
|
|
124
|
+
if (r.remediation)
|
|
125
|
+
lines.push(` → ${r.remediation}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
lines.push('');
|
|
129
|
+
}
|
|
98
130
|
// How to change
|
|
99
131
|
lines.push('To change config: use vt_sentinel_configure tool');
|
|
100
132
|
lines.push('To reset: use vt_sentinel_reset_policy tool');
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "openclaw-plugin-vt-sentinel",
|
|
3
3
|
"name": "VT Sentinel",
|
|
4
4
|
"description": "VirusTotal Sentinel for OpenClaw — malware detection, active protection, and AI-powered code analysis.",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.12.0",
|
|
6
6
|
"skills": ["./skills"],
|
|
7
7
|
"contracts": {
|
|
8
8
|
"tools": [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-plugin-vt-sentinel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"displayName": "VT Sentinel",
|
|
5
5
|
"description": "VirusTotal Sentinel for OpenClaw - Malware detection and AI-powered code analysis",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -51,9 +51,9 @@
|
|
|
51
51
|
"dist/env-access.*",
|
|
52
52
|
"dist/version.*",
|
|
53
53
|
"dist/update-commands.*",
|
|
54
|
+
"dist/compliance-snapshot.*",
|
|
54
55
|
"dist/signatures/**/*.json",
|
|
55
56
|
"skills/",
|
|
56
|
-
"hooks/",
|
|
57
57
|
"openclaw.plugin.json"
|
|
58
58
|
],
|
|
59
59
|
"openclaw": {
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: vt-auto-scan
|
|
3
|
-
description: >-
|
|
4
|
-
Automatically scans files created or downloaded by agent tool calls
|
|
5
|
-
(exec, write, web_fetch) using VirusTotal. Detects malware downloads,
|
|
6
|
-
malicious scripts, and compromised skill files in real-time.
|
|
7
|
-
emoji: "\U0001F6E1\uFE0F"
|
|
8
|
-
events:
|
|
9
|
-
- tool_result_persist
|
|
10
|
-
---
|
|
11
|
-
|
|
12
|
-
# VT Auto-Scan Hook
|
|
13
|
-
|
|
14
|
-
Intercepts tool execution results and automatically scans any files that were
|
|
15
|
-
created, downloaded, or modified during the tool call. This provides a
|
|
16
|
-
transparent security layer that catches malicious payloads regardless of
|
|
17
|
-
which skill or command originated the download.
|
|
18
|
-
|
|
19
|
-
Files read by the agent (via the `read` tool) are hash-checked only — never
|
|
20
|
-
auto-uploaded to VirusTotal. This protects configuration and instruction files
|
|
21
|
-
from being shared without explicit consent.
|
|
22
|
-
|
|
23
|
-
When `autoScan` is set to `false`, hook scanning is disabled entirely.
|
|
24
|
-
Active blocking (dangerous command patterns, blocklist enforcement) remains
|
|
25
|
-
always-on regardless of this setting.
|
|
26
|
-
|
|
27
|
-
## Detected Patterns
|
|
28
|
-
|
|
29
|
-
- `curl -o /path/file`, `wget -O /path/file` — download targets
|
|
30
|
-
- `> /path/file`, `tee /path/file` — redirect outputs
|
|
31
|
-
- `bash /path/script.sh`, `python /path/script.py` — executed scripts
|
|
32
|
-
- Files written via `write` / `edit` tools
|
|
33
|
-
- File paths appearing in tool output in monitored directories
|
|
@@ -1,174 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Standalone hook handler for vt-auto-scan.
|
|
3
|
-
* This handler works when loaded independently by OpenClaw's hook discovery.
|
|
4
|
-
* It creates its own scanner instance from the environment.
|
|
5
|
-
*
|
|
6
|
-
* Supports both standard VT API (user key) and VTAI (cached agent credentials).
|
|
7
|
-
* For the integrated plugin path, the hook is registered directly in index.ts.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
const path = require('path');
|
|
11
|
-
const fs = require('fs');
|
|
12
|
-
|
|
13
|
-
let Scanner, extractPaths, loadAgentCredentials, ConfigManager, matchGlob, isSelfPath, StateStore;
|
|
14
|
-
|
|
15
|
-
try {
|
|
16
|
-
// Try to load from the compiled plugin
|
|
17
|
-
const distDir = path.resolve(__dirname, '../../dist');
|
|
18
|
-
Scanner = require(path.join(distDir, 'scanner')).Scanner;
|
|
19
|
-
extractPaths = require(path.join(distDir, 'path-extractor')).extractPaths;
|
|
20
|
-
loadAgentCredentials = require(path.join(distDir, 'vt-api')).loadAgentCredentials;
|
|
21
|
-
ConfigManager = require(path.join(distDir, 'config-manager')).ConfigManager;
|
|
22
|
-
matchGlob = require(path.join(distDir, 'config-manager')).matchGlob;
|
|
23
|
-
isSelfPath = require(path.join(distDir, 'index')).isSelfPath;
|
|
24
|
-
StateStore = require(path.join(distDir, 'state-store')).StateStore;
|
|
25
|
-
} catch (e) {
|
|
26
|
-
// Modules not available — this hook won't function standalone
|
|
27
|
-
console.error('[vt-auto-scan] Could not load scanner modules:', e.message);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Read static plugin config from OpenClaw config file.
|
|
32
|
-
* Returns null if not found or on error.
|
|
33
|
-
*/
|
|
34
|
-
function readStaticConfig() {
|
|
35
|
-
try {
|
|
36
|
-
const stateDir = process.env.OPENCLAW_STATE_DIR || path.join(require('os').homedir(), '.openclaw');
|
|
37
|
-
const configPath = path.join(stateDir, 'openclaw.json');
|
|
38
|
-
if (!fs.existsSync(configPath)) return null;
|
|
39
|
-
const raw = fs.readFileSync(configPath, 'utf-8');
|
|
40
|
-
const parsed = (() => {
|
|
41
|
-
try { return require('json5').parse(raw); } catch { return JSON.parse(raw); }
|
|
42
|
-
})();
|
|
43
|
-
return parsed?.plugins?.entries?.['openclaw-plugin-vt-sentinel']?.config || null;
|
|
44
|
-
} catch {
|
|
45
|
-
return null;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Create a scanner instance using available credentials.
|
|
51
|
-
* Priority: user apiKey from static config > cached VTAI agent token.
|
|
52
|
-
*
|
|
53
|
-
* v0.11.0 change: removed the `process.env.VIRUSTOTAL_API_KEY` lookup + the
|
|
54
|
-
* `'vtai-active'` env sentinel. The main plugin no longer mutates the
|
|
55
|
-
* environment, so this standalone hook mirrors that stance: credential source
|
|
56
|
-
* is 100% derived from openclaw.json (user apiKey) or the persisted agent
|
|
57
|
-
* credentials file (VTAI mode).
|
|
58
|
-
*/
|
|
59
|
-
function createScanner(logger, eff, staticConfig) {
|
|
60
|
-
const userKey = staticConfig && typeof staticConfig.apiKey === 'string' && staticConfig.apiKey.trim().length > 0
|
|
61
|
-
? staticConfig.apiKey.trim()
|
|
62
|
-
: null;
|
|
63
|
-
|
|
64
|
-
if (userKey) {
|
|
65
|
-
return new Scanner(userKey, logger, eff.maxFileSizeMb, eff.sensitiveFilePolicy, false, eff.semanticFilePolicy);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// Try VTAI cached credentials
|
|
69
|
-
if (loadAgentCredentials) {
|
|
70
|
-
try {
|
|
71
|
-
const creds = loadAgentCredentials();
|
|
72
|
-
if (creds) {
|
|
73
|
-
return new Scanner(creds.agentToken, logger, eff.maxFileSizeMb, eff.sensitiveFilePolicy, true, eff.semanticFilePolicy);
|
|
74
|
-
}
|
|
75
|
-
} catch (e) {
|
|
76
|
-
// Credentials not available
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
return null;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const handler = async (event) => {
|
|
84
|
-
// Only handle tool result events
|
|
85
|
-
if (event.type !== 'tool_result_persist' && event.type !== 'tool_result') return;
|
|
86
|
-
|
|
87
|
-
if (!Scanner || !extractPaths) return;
|
|
88
|
-
|
|
89
|
-
// Load config (static + persisted overrides) and check autoScan
|
|
90
|
-
let configManager = null;
|
|
91
|
-
const staticConfig = readStaticConfig();
|
|
92
|
-
if (ConfigManager) {
|
|
93
|
-
configManager = new ConfigManager(staticConfig);
|
|
94
|
-
// Apply persisted runtime overrides from vt-sentinel-state.json
|
|
95
|
-
if (StateStore) {
|
|
96
|
-
try {
|
|
97
|
-
const stateStore = new StateStore();
|
|
98
|
-
configManager.loadPersistedOverrides(stateStore.getPersistedOverrides());
|
|
99
|
-
} catch {
|
|
100
|
-
// State file missing or corrupt — proceed with static config only
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
const eff = configManager ? configManager.getEffective() : {
|
|
105
|
-
autoScan: true, maxFileSizeMb: 32,
|
|
106
|
-
sensitiveFilePolicy: 'ask', semanticFilePolicy: 'hash_only',
|
|
107
|
-
excludeGlobs: [],
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
// autoScan=false disables hook scanning
|
|
111
|
-
if (!eff.autoScan) return;
|
|
112
|
-
|
|
113
|
-
const logger = {
|
|
114
|
-
info: (m) => console.log(m),
|
|
115
|
-
warn: (m) => console.warn(m),
|
|
116
|
-
error: (m) => console.error(m),
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
const scanner = createScanner(logger, eff, staticConfig);
|
|
120
|
-
if (!scanner) return;
|
|
121
|
-
|
|
122
|
-
const toolName = event.toolName || event.tool || '';
|
|
123
|
-
const toolParams = event.toolParams || event.params || event.input || {};
|
|
124
|
-
|
|
125
|
-
// Extract result text
|
|
126
|
-
let resultText = '';
|
|
127
|
-
if (typeof event.toolResult === 'string') resultText = event.toolResult;
|
|
128
|
-
else if (event.toolResult?.stdout) resultText = event.toolResult.stdout;
|
|
129
|
-
else if (event.toolResult?.content && Array.isArray(event.toolResult.content)) {
|
|
130
|
-
resultText = event.toolResult.content
|
|
131
|
-
.filter(p => p.type === 'text')
|
|
132
|
-
.map(p => p.text)
|
|
133
|
-
.join('\n');
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
const targets = extractPaths(toolName, toolParams, resultText);
|
|
137
|
-
if (targets.length === 0) return;
|
|
138
|
-
|
|
139
|
-
for (const target of targets) {
|
|
140
|
-
// Self-exclusion
|
|
141
|
-
if (isSelfPath && isSelfPath(target.path)) continue;
|
|
142
|
-
|
|
143
|
-
// excludeGlobs: skip files matching any exclude pattern
|
|
144
|
-
if (matchGlob && eff.excludeGlobs && eff.excludeGlobs.length > 0) {
|
|
145
|
-
let excluded = false;
|
|
146
|
-
for (const glob of eff.excludeGlobs) {
|
|
147
|
-
if (matchGlob(target.path, glob)) { excluded = true; break; }
|
|
148
|
-
}
|
|
149
|
-
if (excluded) continue;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
try {
|
|
153
|
-
// read_target → hash-only (never upload files the agent merely reads)
|
|
154
|
-
const isReadTarget = target.source === 'read_target';
|
|
155
|
-
const result = await scanner.scanFile(target.path, false, undefined, isReadTarget);
|
|
156
|
-
if (result.verdict === 'malicious' || result.verdict === 'suspicious') {
|
|
157
|
-
const warning = `\n\n⚠️ VT-SENTINEL SECURITY ALERT ⚠️\n` +
|
|
158
|
-
`File: ${result.fileName} | Verdict: ${result.verdict.toUpperCase()}\n` +
|
|
159
|
-
`${result.vtLink || ''}`;
|
|
160
|
-
|
|
161
|
-
if (event.toolResult?.content && Array.isArray(event.toolResult.content)) {
|
|
162
|
-
event.toolResult.content.push({ type: 'text', text: warning });
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
logger.error(`[VT-Sentinel] ${result.verdict.toUpperCase()}: ${result.fileName} — ${result.message}`);
|
|
166
|
-
}
|
|
167
|
-
} catch (err) {
|
|
168
|
-
logger.error(`[vt-auto-scan] Scan error: ${err.message}`);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
};
|
|
172
|
-
|
|
173
|
-
module.exports = handler;
|
|
174
|
-
module.exports.default = handler;
|