ship-safe 6.4.0 → 7.0.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/README.md +0 -2
- package/cli/agents/agent-config-scanner.js +15 -0
- package/cli/agents/deep-analyzer.js +39 -19
- package/cli/agents/index.js +4 -1
- package/cli/agents/memory-poisoning-agent.js +304 -0
- package/cli/agents/scoring-engine.js +16 -1
- package/cli/agents/supply-chain-agent.js +128 -2
- package/cli/bin/ship-safe.js +53 -0
- package/cli/commands/live-advisories.js +241 -0
- package/cli/commands/watch.js +205 -0
- package/cli/providers/llm-provider.js +89 -1
- package/cli/utils/compliance-map.js +66 -0
- package/package.json +2 -2
package/cli/bin/ship-safe.js
CHANGED
|
@@ -47,6 +47,7 @@ import { abomCommand } from '../commands/abom.js';
|
|
|
47
47
|
import { updateIntelCommand } from '../commands/update-intel.js';
|
|
48
48
|
import { hooksCommand } from '../commands/hooks.js';
|
|
49
49
|
import { legalCommand } from '../commands/legal.js';
|
|
50
|
+
import { runLiveAdvisories } from '../commands/live-advisories.js';
|
|
50
51
|
import { ABOMGenerator } from '../agents/abom-generator.js';
|
|
51
52
|
import { PolicyEngine } from '../agents/policy-engine.js';
|
|
52
53
|
import { SBOMGenerator } from '../agents/sbom-generator.js';
|
|
@@ -268,8 +269,60 @@ program
|
|
|
268
269
|
.description('Continuous monitoring: watch files for security issues in real-time')
|
|
269
270
|
.option('--poll', 'Use polling mode (for network drives)')
|
|
270
271
|
.option('--configs', 'Watch only agent config files (openclaw.json, .cursorrules, mcp.json, etc.)')
|
|
272
|
+
.option('--deep', 'Run full agent scanning on changes (not just pattern matching)')
|
|
273
|
+
.option('--status', 'Show current watch status and exit')
|
|
274
|
+
.option('--threshold <score>', 'Alert when score drops below threshold', parseInt)
|
|
275
|
+
.option('--debounce <ms>', 'Debounce interval in ms (default: 1500)', parseInt)
|
|
271
276
|
.action(watchCommand);
|
|
272
277
|
|
|
278
|
+
// -----------------------------------------------------------------------------
|
|
279
|
+
// ADVISORIES COMMAND
|
|
280
|
+
// -----------------------------------------------------------------------------
|
|
281
|
+
program
|
|
282
|
+
.command('advisories [path]')
|
|
283
|
+
.description('Check dependencies against live advisory feeds (OSV.dev, GitHub Advisories)')
|
|
284
|
+
.option('--ecosystem <type>', 'Filter by ecosystem (npm, PyPI)')
|
|
285
|
+
.option('--json', 'Output as JSON')
|
|
286
|
+
.action(async (targetPath = '.', options) => {
|
|
287
|
+
const { resolve } = await import('path');
|
|
288
|
+
const absolutePath = resolve(targetPath);
|
|
289
|
+
try {
|
|
290
|
+
const result = await runLiveAdvisories(absolutePath, options);
|
|
291
|
+
if (options.json) {
|
|
292
|
+
console.log(JSON.stringify(result, null, 2));
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
console.log();
|
|
296
|
+
console.log(chalk.cyan.bold(' Ship Safe — Live Advisories'));
|
|
297
|
+
console.log(chalk.gray(` Checked ${result.checked} dependencies against OSV.dev`));
|
|
298
|
+
console.log();
|
|
299
|
+
if (result.advisories.length === 0) {
|
|
300
|
+
console.log(chalk.green(' ✔ No known advisories for your current dependency versions.\n'));
|
|
301
|
+
} else {
|
|
302
|
+
const malware = result.advisories.filter(a => a.isMalware);
|
|
303
|
+
const vulns = result.advisories.filter(a => !a.isMalware);
|
|
304
|
+
if (malware.length > 0) {
|
|
305
|
+
console.log(chalk.red.bold(` !! ${malware.length} MALWARE ADVISORY(S) FOUND`));
|
|
306
|
+
for (const a of malware) {
|
|
307
|
+
console.log(chalk.red(` ${a.package}@${a.version} — ${a.id}: ${a.summary.slice(0, 80)}`));
|
|
308
|
+
}
|
|
309
|
+
console.log();
|
|
310
|
+
}
|
|
311
|
+
if (vulns.length > 0) {
|
|
312
|
+
console.log(chalk.yellow(` ${vulns.length} vulnerability advisory(s):`));
|
|
313
|
+
for (const a of vulns) {
|
|
314
|
+
const sev = a.severity === 'critical' ? chalk.red.bold(a.severity) : a.severity === 'high' ? chalk.yellow(a.severity) : chalk.blue(a.severity);
|
|
315
|
+
console.log(` ${sev} ${a.package}@${a.version} — ${a.id}`);
|
|
316
|
+
}
|
|
317
|
+
console.log();
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
} catch (err) {
|
|
321
|
+
console.error(chalk.red(` Error: ${err.message}\n`));
|
|
322
|
+
process.exit(1);
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
|
|
273
326
|
// -----------------------------------------------------------------------------
|
|
274
327
|
// SBOM COMMAND
|
|
275
328
|
// -----------------------------------------------------------------------------
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live Advisory Feed
|
|
3
|
+
* ===================
|
|
4
|
+
*
|
|
5
|
+
* Queries the GitHub Advisory Database and OSV.dev API for real-time
|
|
6
|
+
* advisories on your exact dependency versions. Unlike static CVE checks,
|
|
7
|
+
* this catches actively-compromised packages (Axios 1.8.2, LiteLLM 1.82.7)
|
|
8
|
+
* within hours of publication.
|
|
9
|
+
*
|
|
10
|
+
* USAGE:
|
|
11
|
+
* ship-safe advisories . # Check npm + PyPI deps
|
|
12
|
+
* ship-safe advisories . --ecosystem npm
|
|
13
|
+
* ship-safe advisories . --json
|
|
14
|
+
*
|
|
15
|
+
* APIs used:
|
|
16
|
+
* - OSV.dev (https://api.osv.dev) — aggregates GitHub Advisories, PyPI, npm
|
|
17
|
+
* - No API key needed — fully open, rate-limited to 1000 req/min
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import fs from 'fs';
|
|
21
|
+
import path from 'path';
|
|
22
|
+
|
|
23
|
+
// =============================================================================
|
|
24
|
+
// DEPENDENCY EXTRACTION
|
|
25
|
+
// =============================================================================
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Extract package names + versions from project manifests.
|
|
29
|
+
* Returns: [{ name, version, ecosystem, file }]
|
|
30
|
+
*/
|
|
31
|
+
export function extractDependencies(rootPath) {
|
|
32
|
+
const deps = [];
|
|
33
|
+
|
|
34
|
+
// npm / Node.js
|
|
35
|
+
const pkgPath = path.join(rootPath, 'package.json');
|
|
36
|
+
if (fs.existsSync(pkgPath)) {
|
|
37
|
+
try {
|
|
38
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
39
|
+
const allDeps = {
|
|
40
|
+
...(pkg.dependencies || {}),
|
|
41
|
+
...(pkg.devDependencies || {}),
|
|
42
|
+
};
|
|
43
|
+
for (const [name, versionRange] of Object.entries(allDeps)) {
|
|
44
|
+
// Strip semver prefix (^, ~, >=)
|
|
45
|
+
const version = String(versionRange).replace(/^[\^~>=<]+/, '').trim();
|
|
46
|
+
if (/^\d/.test(version)) {
|
|
47
|
+
deps.push({ name, version, ecosystem: 'npm', file: pkgPath });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} catch { /* skip */ }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Also check package-lock.json for pinned versions (more accurate)
|
|
54
|
+
const lockPath = path.join(rootPath, 'package-lock.json');
|
|
55
|
+
if (fs.existsSync(lockPath)) {
|
|
56
|
+
try {
|
|
57
|
+
const lock = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
|
|
58
|
+
const packages = lock.packages || {};
|
|
59
|
+
for (const [pkgKey, info] of Object.entries(packages)) {
|
|
60
|
+
if (!pkgKey || pkgKey === '') continue; // root entry
|
|
61
|
+
const name = pkgKey.replace(/^node_modules\//, '');
|
|
62
|
+
if (info.version && /^\d/.test(info.version)) {
|
|
63
|
+
// Only add if not already present from package.json
|
|
64
|
+
if (!deps.find(d => d.name === name && d.ecosystem === 'npm')) {
|
|
65
|
+
deps.push({ name, version: info.version, ecosystem: 'npm', file: lockPath });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} catch { /* skip */ }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Python
|
|
73
|
+
const reqPath = path.join(rootPath, 'requirements.txt');
|
|
74
|
+
if (fs.existsSync(reqPath)) {
|
|
75
|
+
try {
|
|
76
|
+
const lines = fs.readFileSync(reqPath, 'utf-8').split('\n');
|
|
77
|
+
for (const line of lines) {
|
|
78
|
+
const m = line.trim().match(/^([\w-]+)==([\d.]+)/);
|
|
79
|
+
if (m) {
|
|
80
|
+
deps.push({ name: m[1], version: m[2], ecosystem: 'PyPI', file: reqPath });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch { /* skip */ }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Poetry (pyproject.toml)
|
|
87
|
+
const pyprojectPath = path.join(rootPath, 'pyproject.toml');
|
|
88
|
+
if (fs.existsSync(pyprojectPath)) {
|
|
89
|
+
try {
|
|
90
|
+
const content = fs.readFileSync(pyprojectPath, 'utf-8');
|
|
91
|
+
const depSection = content.match(/\[tool\.poetry\.dependencies\]([\s\S]*?)(?:\n\[|$)/);
|
|
92
|
+
if (depSection) {
|
|
93
|
+
const lines = depSection[1].split('\n');
|
|
94
|
+
for (const line of lines) {
|
|
95
|
+
const m = line.match(/^([\w-]+)\s*=\s*"([\d.]+)"/);
|
|
96
|
+
if (m) {
|
|
97
|
+
deps.push({ name: m[1], version: m[2], ecosystem: 'PyPI', file: pyprojectPath });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} catch { /* skip */ }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return deps;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// =============================================================================
|
|
108
|
+
// OSV.DEV API
|
|
109
|
+
// =============================================================================
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Query OSV.dev for known vulnerabilities affecting a specific package version.
|
|
113
|
+
* Uses the batch query endpoint for efficiency.
|
|
114
|
+
*
|
|
115
|
+
* @param {{ name: string, version: string, ecosystem: string }[]} deps
|
|
116
|
+
* @returns {Promise<object[]>} — Array of advisory objects
|
|
117
|
+
*/
|
|
118
|
+
export async function queryOSV(deps) {
|
|
119
|
+
if (deps.length === 0) return [];
|
|
120
|
+
|
|
121
|
+
// OSV batch query supports up to 1000 packages per request
|
|
122
|
+
const batchSize = 1000;
|
|
123
|
+
const allResults = [];
|
|
124
|
+
|
|
125
|
+
for (let i = 0; i < deps.length; i += batchSize) {
|
|
126
|
+
const batch = deps.slice(i, i + batchSize);
|
|
127
|
+
const queries = batch.map(d => ({
|
|
128
|
+
package: { name: d.name, ecosystem: d.ecosystem },
|
|
129
|
+
version: d.version,
|
|
130
|
+
}));
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const response = await fetch('https://api.osv.dev/v1/querybatch', {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
headers: { 'Content-Type': 'application/json' },
|
|
136
|
+
body: JSON.stringify({ queries }),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
if (!response.ok) {
|
|
140
|
+
throw new Error(`OSV API error: HTTP ${response.status}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const data = await response.json();
|
|
144
|
+
const results = data.results || [];
|
|
145
|
+
|
|
146
|
+
for (let j = 0; j < results.length; j++) {
|
|
147
|
+
const vulns = results[j].vulns || [];
|
|
148
|
+
for (const vuln of vulns) {
|
|
149
|
+
allResults.push({
|
|
150
|
+
id: vuln.id,
|
|
151
|
+
summary: vuln.summary || '',
|
|
152
|
+
severity: extractSeverity(vuln),
|
|
153
|
+
package: batch[j].name,
|
|
154
|
+
version: batch[j].version,
|
|
155
|
+
ecosystem: batch[j].ecosystem,
|
|
156
|
+
file: deps[i + j].file,
|
|
157
|
+
aliases: vuln.aliases || [],
|
|
158
|
+
published: vuln.published || null,
|
|
159
|
+
modified: vuln.modified || null,
|
|
160
|
+
isMalware: (vuln.id || '').startsWith('MAL-') ||
|
|
161
|
+
(vuln.summary || '').toLowerCase().includes('malicious') ||
|
|
162
|
+
(vuln.summary || '').toLowerCase().includes('malware'),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
} catch (err) {
|
|
167
|
+
// Network error — return what we have so far
|
|
168
|
+
if (allResults.length === 0) {
|
|
169
|
+
throw new Error(`Failed to reach OSV.dev: ${err.message}. Run with --offline to skip live checks.`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return allResults;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Extract the highest severity from an OSV vulnerability object.
|
|
179
|
+
*/
|
|
180
|
+
function extractSeverity(vuln) {
|
|
181
|
+
// Check database_specific severity first
|
|
182
|
+
if (vuln.database_specific?.severity) {
|
|
183
|
+
return vuln.database_specific.severity.toLowerCase();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Check CVSS in severity array
|
|
187
|
+
const sevEntries = vuln.severity || [];
|
|
188
|
+
for (const entry of sevEntries) {
|
|
189
|
+
if (entry.type === 'CVSS_V3') {
|
|
190
|
+
const score = parseFloat(entry.score) || 0;
|
|
191
|
+
if (score >= 9.0) return 'critical';
|
|
192
|
+
if (score >= 7.0) return 'high';
|
|
193
|
+
if (score >= 4.0) return 'medium';
|
|
194
|
+
return 'low';
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Malware is always critical
|
|
199
|
+
if ((vuln.id || '').startsWith('MAL-')) return 'critical';
|
|
200
|
+
|
|
201
|
+
return 'medium';
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// =============================================================================
|
|
205
|
+
// MAIN COMMAND
|
|
206
|
+
// =============================================================================
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Run the live advisory check.
|
|
210
|
+
* Returns findings in ship-safe standard format.
|
|
211
|
+
*/
|
|
212
|
+
export async function runLiveAdvisories(rootPath, options = {}) {
|
|
213
|
+
const deps = extractDependencies(rootPath);
|
|
214
|
+
|
|
215
|
+
if (deps.length === 0) {
|
|
216
|
+
return { advisories: [], deps: 0, checked: 0 };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Filter by ecosystem if requested
|
|
220
|
+
const filtered = options.ecosystem
|
|
221
|
+
? deps.filter(d => d.ecosystem.toLowerCase() === options.ecosystem.toLowerCase())
|
|
222
|
+
: deps;
|
|
223
|
+
|
|
224
|
+
const advisories = await queryOSV(filtered);
|
|
225
|
+
|
|
226
|
+
// Sort: malware first, then by severity
|
|
227
|
+
const sevOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
228
|
+
advisories.sort((a, b) => {
|
|
229
|
+
if (a.isMalware && !b.isMalware) return -1;
|
|
230
|
+
if (!a.isMalware && b.isMalware) return 1;
|
|
231
|
+
return (sevOrder[a.severity] || 2) - (sevOrder[b.severity] || 2);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
advisories,
|
|
236
|
+
deps: filtered.length,
|
|
237
|
+
checked: filtered.length,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export default { extractDependencies, queryOSV, runLiveAdvisories };
|
package/cli/commands/watch.js
CHANGED
|
@@ -16,6 +16,7 @@ import chalk from 'chalk';
|
|
|
16
16
|
import { SKIP_DIRS, SKIP_EXTENSIONS, SKIP_FILENAMES, SECRET_PATTERNS, SECURITY_PATTERNS } from '../utils/patterns.js';
|
|
17
17
|
import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
|
|
18
18
|
import * as output from '../utils/output.js';
|
|
19
|
+
import { ScoringEngine } from '../agents/scoring-engine.js';
|
|
19
20
|
|
|
20
21
|
// Agent config files to watch
|
|
21
22
|
const AGENT_CONFIG_PATTERNS = [
|
|
@@ -26,6 +27,10 @@ const AGENT_CONFIG_PATTERNS = [
|
|
|
26
27
|
'.cursor/mcp.json', '.vscode/mcp.json',
|
|
27
28
|
];
|
|
28
29
|
|
|
30
|
+
// Watch state persistence
|
|
31
|
+
const WATCH_DB_DIR = '.ship-safe';
|
|
32
|
+
const WATCH_DB_FILE = 'watch.json';
|
|
33
|
+
|
|
29
34
|
export async function watchCommand(targetPath = '.', options = {}) {
|
|
30
35
|
const absolutePath = path.resolve(targetPath);
|
|
31
36
|
|
|
@@ -34,15 +39,26 @@ export async function watchCommand(targetPath = '.', options = {}) {
|
|
|
34
39
|
process.exit(1);
|
|
35
40
|
}
|
|
36
41
|
|
|
42
|
+
// Status mode: print current watch state and exit
|
|
43
|
+
if (options.status) {
|
|
44
|
+
return showWatchStatus(absolutePath);
|
|
45
|
+
}
|
|
46
|
+
|
|
37
47
|
// Config-only watch mode
|
|
38
48
|
if (options.configs) {
|
|
39
49
|
return watchConfigs(absolutePath);
|
|
40
50
|
}
|
|
41
51
|
|
|
52
|
+
// Deep mode: run full orchestrator on changes
|
|
53
|
+
if (options.deep) {
|
|
54
|
+
return watchDeep(absolutePath, options);
|
|
55
|
+
}
|
|
56
|
+
|
|
42
57
|
console.log();
|
|
43
58
|
output.header('Ship Safe — Watch Mode');
|
|
44
59
|
console.log();
|
|
45
60
|
console.log(chalk.cyan(' Watching for file changes...'));
|
|
61
|
+
console.log(chalk.gray(' Use --deep for full agent scanning, --status for current findings'));
|
|
46
62
|
console.log(chalk.gray(' Press Ctrl+C to stop'));
|
|
47
63
|
console.log();
|
|
48
64
|
|
|
@@ -230,6 +246,195 @@ async function watchConfigs(absolutePath) {
|
|
|
230
246
|
}
|
|
231
247
|
}
|
|
232
248
|
|
|
249
|
+
// =============================================================================
|
|
250
|
+
// STATUS MODE
|
|
251
|
+
// =============================================================================
|
|
252
|
+
|
|
253
|
+
function showWatchStatus(rootPath) {
|
|
254
|
+
const dbFile = path.join(rootPath, WATCH_DB_DIR, WATCH_DB_FILE);
|
|
255
|
+
if (!fs.existsSync(dbFile)) {
|
|
256
|
+
console.log('\n No watch data found. Run: ship-safe watch . --deep\n');
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
const data = JSON.parse(fs.readFileSync(dbFile, 'utf-8'));
|
|
262
|
+
console.log(`\n ${chalk.cyan.bold('Ship Safe Watch — Status')}`);
|
|
263
|
+
console.log(` ${'─'.repeat(40)}`);
|
|
264
|
+
console.log(` Last scan: ${data.lastScan || 'never'}`);
|
|
265
|
+
console.log(` Scans run: ${data.scanCount || 0}`);
|
|
266
|
+
console.log(` Score: ${data.score?.score ?? '?'}/100 ${data.score?.grade ?? ''}`);
|
|
267
|
+
console.log(` Findings: ${data.score?.totalFindings ?? 0}`);
|
|
268
|
+
|
|
269
|
+
if (data.agentic) {
|
|
270
|
+
console.log(` Agentic: ${data.agentic.flagged}/${data.agentic.total} OWASP Agentic risks flagged`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Severity breakdown
|
|
274
|
+
const sevCounts = { critical: 0, high: 0, medium: 0, low: 0 };
|
|
275
|
+
for (const f of (data.findings || [])) {
|
|
276
|
+
sevCounts[f.severity] = (sevCounts[f.severity] || 0) + 1;
|
|
277
|
+
}
|
|
278
|
+
console.log(` Critical: ${sevCounts.critical}`);
|
|
279
|
+
console.log(` High: ${sevCounts.high}`);
|
|
280
|
+
console.log(` Medium: ${sevCounts.medium}`);
|
|
281
|
+
console.log(` Low: ${sevCounts.low}\n`);
|
|
282
|
+
} catch {
|
|
283
|
+
console.log('\n Failed to read watch data. File may be corrupted.\n');
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// =============================================================================
|
|
288
|
+
// DEEP WATCH MODE (full orchestrator)
|
|
289
|
+
// =============================================================================
|
|
290
|
+
|
|
291
|
+
async function watchDeep(absolutePath, options = {}) {
|
|
292
|
+
const { buildOrchestrator } = await import('../agents/index.js');
|
|
293
|
+
const { ReconAgent } = await import('../agents/recon-agent.js');
|
|
294
|
+
|
|
295
|
+
const debounceMs = options.debounce || 1500;
|
|
296
|
+
const threshold = options.threshold || null;
|
|
297
|
+
const scoringEngine = new ScoringEngine();
|
|
298
|
+
|
|
299
|
+
console.log();
|
|
300
|
+
output.header('Ship Safe — Deep Watch Mode');
|
|
301
|
+
console.log();
|
|
302
|
+
console.log(chalk.cyan(' Running full agent scans on file changes'));
|
|
303
|
+
console.log(chalk.gray(` Debounce: ${debounceMs}ms`));
|
|
304
|
+
if (threshold) console.log(chalk.gray(` Threshold: ${threshold}/100`));
|
|
305
|
+
console.log(chalk.gray(' Press Ctrl+C to stop'));
|
|
306
|
+
console.log();
|
|
307
|
+
|
|
308
|
+
// Initial recon
|
|
309
|
+
const reconAgent = new ReconAgent();
|
|
310
|
+
console.log(chalk.gray(' Running initial recon...'));
|
|
311
|
+
let recon;
|
|
312
|
+
try {
|
|
313
|
+
const reconResults = await reconAgent.analyze({ rootPath: absolutePath });
|
|
314
|
+
recon = Array.isArray(reconResults) ? {} : reconResults;
|
|
315
|
+
} catch { recon = {}; }
|
|
316
|
+
console.log(chalk.gray(' Recon complete. Watching...\n'));
|
|
317
|
+
|
|
318
|
+
let pendingFiles = new Set();
|
|
319
|
+
let debounceTimer = null;
|
|
320
|
+
let scanCount = 0;
|
|
321
|
+
|
|
322
|
+
const dbDir = path.join(absolutePath, WATCH_DB_DIR);
|
|
323
|
+
const dbFile = path.join(dbDir, WATCH_DB_FILE);
|
|
324
|
+
|
|
325
|
+
const processChanges = async () => {
|
|
326
|
+
const files = [...pendingFiles];
|
|
327
|
+
pendingFiles.clear();
|
|
328
|
+
if (files.length === 0) return;
|
|
329
|
+
|
|
330
|
+
scanCount++;
|
|
331
|
+
const timestamp = new Date().toLocaleTimeString();
|
|
332
|
+
console.log(chalk.gray(` [${timestamp}] ${files.length} file(s) changed — deep scanning...`));
|
|
333
|
+
|
|
334
|
+
try {
|
|
335
|
+
const orchestrator = buildOrchestrator();
|
|
336
|
+
const context = {
|
|
337
|
+
rootPath: absolutePath,
|
|
338
|
+
files,
|
|
339
|
+
changedFiles: files,
|
|
340
|
+
recon,
|
|
341
|
+
options: { incremental: true },
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const findings = await orchestrator.run(context);
|
|
345
|
+
const scoreResult = scoringEngine.compute(findings);
|
|
346
|
+
|
|
347
|
+
// Persist results
|
|
348
|
+
try {
|
|
349
|
+
if (!fs.existsSync(dbDir)) fs.mkdirSync(dbDir, { recursive: true });
|
|
350
|
+
fs.writeFileSync(dbFile, JSON.stringify({
|
|
351
|
+
lastScan: new Date().toISOString(),
|
|
352
|
+
scanCount,
|
|
353
|
+
score: {
|
|
354
|
+
score: scoreResult.score,
|
|
355
|
+
grade: scoreResult.grade?.letter,
|
|
356
|
+
totalFindings: scoreResult.totalFindings,
|
|
357
|
+
},
|
|
358
|
+
agentic: scoreResult.agenticSummary
|
|
359
|
+
? { flagged: scoreResult.agenticSummary.flagged, total: scoreResult.agenticSummary.total }
|
|
360
|
+
: null,
|
|
361
|
+
findings: findings.map(f => ({
|
|
362
|
+
file: path.relative(absolutePath, f.file || ''),
|
|
363
|
+
line: f.line,
|
|
364
|
+
severity: f.severity,
|
|
365
|
+
rule: f.rule,
|
|
366
|
+
title: f.title,
|
|
367
|
+
agenticRisk: f.agenticRisk || null,
|
|
368
|
+
})),
|
|
369
|
+
}, null, 2));
|
|
370
|
+
} catch { /* non-fatal */ }
|
|
371
|
+
|
|
372
|
+
// Output
|
|
373
|
+
const criticals = findings.filter(f => f.severity === 'critical').length;
|
|
374
|
+
const highs = findings.filter(f => f.severity === 'high').length;
|
|
375
|
+
|
|
376
|
+
if (findings.length === 0) {
|
|
377
|
+
console.log(chalk.green(` [${timestamp}] ✔ Clean — Score: ${scoreResult.score}/100 ${scoreResult.grade?.letter}\n`));
|
|
378
|
+
} else {
|
|
379
|
+
const scoreColor = scoreResult.score >= 75 ? chalk.cyan : scoreResult.score >= 50 ? chalk.yellow : chalk.red;
|
|
380
|
+
console.log(` [${timestamp}] ${chalk.white(`${findings.length} finding(s)`)}: ${criticals ? chalk.red.bold(`${criticals} critical`) : ''}${criticals && highs ? ', ' : ''}${highs ? chalk.yellow(`${highs} high`) : ''}. Score: ${scoreColor(`${scoreResult.score}/100 ${scoreResult.grade?.letter}`)}`);
|
|
381
|
+
|
|
382
|
+
for (const f of findings.filter(f => f.severity === 'critical' || f.severity === 'high')) {
|
|
383
|
+
const relFile = path.relative(absolutePath, f.file || '');
|
|
384
|
+
const sev = f.severity === 'critical' ? chalk.red.bold('!!') : chalk.yellow(' !');
|
|
385
|
+
const agentic = f.agenticRisk ? chalk.gray(` [${f.agenticRisk.id}]`) : '';
|
|
386
|
+
console.log(` ${sev} ${f.title} — ${relFile}:${f.line}${agentic}`);
|
|
387
|
+
}
|
|
388
|
+
console.log('');
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (threshold && scoreResult.score < threshold) {
|
|
392
|
+
console.log(chalk.red.bold(` ⚠ Score ${scoreResult.score} below threshold ${threshold}\n`));
|
|
393
|
+
}
|
|
394
|
+
} catch (err) {
|
|
395
|
+
console.log(chalk.red(` [${timestamp}] Scan error: ${err.message}\n`));
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
try {
|
|
400
|
+
const watcher = fs.watch(absolutePath, { recursive: true }, (eventType, filename) => {
|
|
401
|
+
if (!filename) return;
|
|
402
|
+
|
|
403
|
+
// Skip non-scannable
|
|
404
|
+
const relPath = filename.replace(/\\/g, '/');
|
|
405
|
+
for (const skipDir of SKIP_DIRS) {
|
|
406
|
+
if (relPath.includes(`${skipDir}/`)) return;
|
|
407
|
+
}
|
|
408
|
+
const ext = path.extname(filename).toLowerCase();
|
|
409
|
+
if (SKIP_EXTENSIONS.has(ext)) return;
|
|
410
|
+
if (SKIP_FILENAMES.has(path.basename(filename))) return;
|
|
411
|
+
if (filename.endsWith('.min.js') || filename.endsWith('.min.css')) return;
|
|
412
|
+
|
|
413
|
+
const fullPath = path.join(absolutePath, filename);
|
|
414
|
+
if (!fs.existsSync(fullPath)) return;
|
|
415
|
+
|
|
416
|
+
pendingFiles.add(fullPath);
|
|
417
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
418
|
+
debounceTimer = setTimeout(processChanges, debounceMs);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
process.on('SIGINT', () => {
|
|
422
|
+
watcher.close();
|
|
423
|
+
console.log(`\n Watch stopped. ${scanCount} scan(s) completed.\n`);
|
|
424
|
+
process.exit(0);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
setInterval(() => {}, 1000 * 60 * 60);
|
|
428
|
+
} catch (err) {
|
|
429
|
+
output.error(`Watch failed: ${err.message}`);
|
|
430
|
+
process.exit(1);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// =============================================================================
|
|
435
|
+
// CONFIG WATCH — scanConfigFiles
|
|
436
|
+
// =============================================================================
|
|
437
|
+
|
|
233
438
|
async function scanConfigFiles(files, rootPath) {
|
|
234
439
|
// Dynamic import to avoid circular dependency
|
|
235
440
|
const { AgentConfigScanner } = await import('../agents/agent-config-scanner.js');
|
|
@@ -196,7 +196,7 @@ class GoogleProvider extends BaseLLMProvider {
|
|
|
196
196
|
class OllamaProvider extends BaseLLMProvider {
|
|
197
197
|
constructor(apiKey, options = {}) {
|
|
198
198
|
super('Ollama', null, options);
|
|
199
|
-
this.model = options.model || '
|
|
199
|
+
this.model = options.model || 'gemma4:e4b';
|
|
200
200
|
this.baseUrl = options.baseUrl || 'http://localhost:11434/api/chat';
|
|
201
201
|
}
|
|
202
202
|
|
|
@@ -223,6 +223,83 @@ class OllamaProvider extends BaseLLMProvider {
|
|
|
223
223
|
}
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
+
// =============================================================================
|
|
227
|
+
// GEMMA 4 PROVIDER
|
|
228
|
+
// Uses Ollama's structured output (format: schema) for guaranteed JSON —
|
|
229
|
+
// no regex parsing, no silent dropped findings.
|
|
230
|
+
// =============================================================================
|
|
231
|
+
|
|
232
|
+
const CLASSIFY_SCHEMA = {
|
|
233
|
+
type: 'object',
|
|
234
|
+
properties: {
|
|
235
|
+
results: {
|
|
236
|
+
type: 'array',
|
|
237
|
+
items: {
|
|
238
|
+
type: 'object',
|
|
239
|
+
properties: {
|
|
240
|
+
id: { type: 'string' },
|
|
241
|
+
classification: { type: 'string', enum: ['REAL', 'FALSE_POSITIVE'] },
|
|
242
|
+
reason: { type: 'string' },
|
|
243
|
+
fix: { type: ['string', 'null'] },
|
|
244
|
+
},
|
|
245
|
+
required: ['id', 'classification', 'reason', 'fix'],
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
required: ['results'],
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
class GemmaProvider extends OllamaProvider {
|
|
253
|
+
constructor(options = {}) {
|
|
254
|
+
super(null, {
|
|
255
|
+
model: options.model || 'gemma4:e4b',
|
|
256
|
+
baseUrl: options.baseUrl || 'http://localhost:11434/api/chat',
|
|
257
|
+
});
|
|
258
|
+
this.name = 'Gemma4';
|
|
259
|
+
// 256K tokens for 27b/31b, 128K for e4b — set conservatively high
|
|
260
|
+
this.contextWindow = options.model?.includes('27b') ? 131072 : 65536;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Classify using Ollama structured output (format: schema).
|
|
265
|
+
* Gemma 4 has trained-in function calling — the schema is enforced at the
|
|
266
|
+
* token level, so the response is always valid JSON matching CLASSIFY_SCHEMA.
|
|
267
|
+
*/
|
|
268
|
+
async classify(findings, context) {
|
|
269
|
+
const prompt = this.buildClassificationPrompt(findings, context);
|
|
270
|
+
|
|
271
|
+
const response = await fetch(this.baseUrl, {
|
|
272
|
+
method: 'POST',
|
|
273
|
+
headers: { 'Content-Type': 'application/json' },
|
|
274
|
+
body: JSON.stringify({
|
|
275
|
+
model: this.model,
|
|
276
|
+
format: CLASSIFY_SCHEMA,
|
|
277
|
+
stream: false,
|
|
278
|
+
options: { num_ctx: this.contextWindow },
|
|
279
|
+
messages: [
|
|
280
|
+
{ role: 'system', content: 'You are a security expert. Classify each finding as REAL or FALSE_POSITIVE and suggest a fix.' },
|
|
281
|
+
{ role: 'user', content: prompt },
|
|
282
|
+
],
|
|
283
|
+
}),
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
if (!response.ok) {
|
|
287
|
+
throw new Error(`Gemma4/Ollama error: HTTP ${response.status}`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const data = await response.json();
|
|
291
|
+
const text = data.message?.content || '';
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
const parsed = JSON.parse(text);
|
|
295
|
+
return parsed.results ?? [];
|
|
296
|
+
} catch {
|
|
297
|
+
// Fallback: schema enforcement failed (old Ollama version) — try regex parse
|
|
298
|
+
return this.parseJSON(text);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
226
303
|
// =============================================================================
|
|
227
304
|
// OPENAI-COMPATIBLE PROVIDER
|
|
228
305
|
// Handles Groq, Together AI, Mistral API, LM Studio, Azure OpenAI, Bedrock
|
|
@@ -239,6 +316,10 @@ const OPENAI_COMPATIBLE_PRESETS = {
|
|
|
239
316
|
perplexity: { baseUrl: 'https://api.perplexity.ai/chat/completions', model: 'llama-3.1-sonar-large-128k-online', envKey: 'PERPLEXITY_API_KEY' },
|
|
240
317
|
lmstudio: { baseUrl: 'http://localhost:1234/v1/chat/completions', model: null, envKey: null },
|
|
241
318
|
xai: { baseUrl: 'https://api.x.ai/v1/chat/completions', model: 'grok-3-mini', envKey: 'XAI_API_KEY' },
|
|
319
|
+
// Gemma 4 via Ollama — runs fully local, no API key required
|
|
320
|
+
// e4b: MoE 4B active params, ~8GB RAM; 27b: dense, ~20GB RAM
|
|
321
|
+
gemma4: { baseUrl: 'http://localhost:11434/v1/chat/completions', model: 'gemma4:e4b', envKey: null },
|
|
322
|
+
'gemma4:27b': { baseUrl: 'http://localhost:11434/v1/chat/completions', model: 'gemma4:27b', envKey: null },
|
|
242
323
|
};
|
|
243
324
|
|
|
244
325
|
class OpenAICompatibleProvider extends OpenAIProvider {
|
|
@@ -279,6 +360,13 @@ export function createProvider(provider, apiKey, options = {}) {
|
|
|
279
360
|
case 'ollama':
|
|
280
361
|
case 'local':
|
|
281
362
|
return new OllamaProvider(apiKey, options);
|
|
363
|
+
case 'gemma4':
|
|
364
|
+
case 'gemma':
|
|
365
|
+
// Gemma 4 via Ollama — structured output, no API key needed
|
|
366
|
+
return new GemmaProvider({
|
|
367
|
+
model: options.model,
|
|
368
|
+
baseUrl: options.baseUrl,
|
|
369
|
+
});
|
|
282
370
|
}
|
|
283
371
|
|
|
284
372
|
// OpenAI-compatible presets
|