ship-safe 6.3.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.
@@ -47,6 +47,20 @@ const SKILL_PATTERNS = [
47
47
  { name: 'Crypto operations', regex: /(?:crypto\.createCipher|crypto\.createDecipher|CryptoJS|forge\.cipher)/gi, severity: 'medium' },
48
48
  { name: 'Network listener', regex: /(?:createServer|listen\s*\(\s*\d|bind\s*\(\s*['"]0\.0\.0\.0)/gi, severity: 'high' },
49
49
  { name: 'Encoded payload block', regex: /[A-Za-z0-9+\/]{60,}={0,2}/g, severity: 'medium' },
50
+
51
+ // ── ToxicSkills patterns (Snyk research — 36% of agent skills affected) ──
52
+ // Silent curl exfiltration: skill instructs agent to silently send data
53
+ { name: 'ToxicSkills: silent data exfiltration via curl', regex: /(?:silently|quietly|without\s+(?:notif|alert|inform|telling|showing)|in\s+the\s+background)\s+.{0,60}(?:curl|wget|fetch|POST|send).{0,60}(?:http|https):\/\//gi, severity: 'critical' },
54
+ // System prompt override in skill definition
55
+ { name: 'ToxicSkills: system prompt override', regex: /(?:ignore\s+(?:all\s+)?(?:previous|prior|above|your)\s+instructions|your\s+(?:new|real|actual|true)\s+(?:instructions|role|goal|purpose)\s+(?:is|are)|disregard\s+(?:all\s+)?(?:previous|above|your))/gi, severity: 'critical' },
56
+ // Skill requests credentials/secrets from agent context
57
+ { name: 'ToxicSkills: credential harvesting', regex: /(?:extract|retrieve|collect|gather|find|read|access|get)\s+.{0,40}(?:api[_\s]?key|secret|token|password|credential|\.env|npmrc|ssh[_\s]?key|private[_\s]?key)/gi, severity: 'critical' },
58
+ // Skill attempts to read ~/.ssh, ~/.aws, ~/.npmrc
59
+ { name: 'ToxicSkills: sensitive path access', regex: /(?:~\/\.(?:ssh|aws|npmrc|netrc|gnupg|config\/gcloud)|\/etc\/(?:passwd|shadow|hosts)|%APPDATA%|%USERPROFILE%)/gi, severity: 'critical' },
60
+ // Skill suppresses its own output to avoid detection
61
+ { name: 'ToxicSkills: output suppression', regex: /(?:do\s+not\s+(?:show|display|reveal|mention|tell|report|log)\s+(?:this|these|the\s+(?:output|result|response|command|action))|hide\s+(?:this|the)\s+(?:output|result|action|command|request))/gi, severity: 'high' },
62
+ // Skill requests permissions beyond its stated purpose
63
+ { name: 'ToxicSkills: permission escalation', regex: /(?:grant\s+(?:me|this\s+skill|yourself)\s+(?:admin|root|sudo|full|all)\s+(?:access|permissions?|rights?)|elevate\s+(?:privileges?|permissions?|rights?)|run\s+as\s+(?:admin|root|sudo))/gi, severity: 'high' },
50
64
  ];
51
65
 
52
66
  // =============================================================================
@@ -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 || 'llama3.2';
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
@@ -50,6 +50,72 @@ const AGENTIC_MAP = {
50
50
  'ASI10': { soc2: ['CC7.2', 'CC7.4'], iso27001: ['A.8.9', 'A.5.30'], nistAiRmf: ['MANAGE 2.2', 'MANAGE 4.1'] },
51
51
  };
52
52
 
53
+ // =============================================================================
54
+ // OWASP AGENTIC AI TOP 10 (December 2025)
55
+ // =============================================================================
56
+
57
+ const OWASP_AGENTIC_TOP_10 = {
58
+ ASI01: { id: 'ASI01', title: 'Agent Goal Hijacking', description: 'Manipulation of agent objectives through prompt injection, memory poisoning, or instruction override.' },
59
+ ASI02: { id: 'ASI02', title: 'Tool Misuse', description: 'Agent uses tools in unintended or dangerous ways — shell execution, file deletion, network access beyond scope.' },
60
+ ASI03: { id: 'ASI03', title: 'Privilege Abuse', description: 'Agent operates with excessive permissions — writes outside project, accesses secrets, escalates access.' },
61
+ ASI04: { id: 'ASI04', title: 'Agentic Supply Chain', description: 'Compromised skills, MCP servers, or tool packages that the agent depends on.' },
62
+ ASI05: { id: 'ASI05', title: 'Memory & Context Poisoning', description: 'Malicious data persisted in agent memory, rules files, or context that survives sessions.' },
63
+ ASI06: { id: 'ASI06', title: 'Uncontrolled Data Exposure', description: 'Agent leaks code, secrets, or PII through tool outputs, logs, or external API calls.' },
64
+ ASI07: { id: 'ASI07', title: 'Insecure Communication', description: 'Unencrypted MCP transport, HTTP model endpoints, or plaintext inter-agent messaging.' },
65
+ ASI08: { id: 'ASI08', title: 'Missing Human Oversight', description: 'Agent takes destructive or irreversible actions without user confirmation — proactive mode risks.' },
66
+ ASI09: { id: 'ASI09', title: 'Weak Identity & Auth', description: 'Agent sessions without authentication, shared API keys, or no audit trail of actions.' },
67
+ ASI10: { id: 'ASI10', title: 'Rogue Agent Behavior', description: 'Agent deviates from intended behavior — self-modification, stealth mode, output suppression.' },
68
+ };
69
+
70
+ /**
71
+ * Enrich a finding with OWASP Agentic Top 10 metadata.
72
+ * Attaches `agenticRisk` object if the finding maps to ASI01–ASI10.
73
+ * @param {object} finding
74
+ * @returns {object} — finding with agenticRisk attached (or unchanged)
75
+ */
76
+ export function enrichAgenticRisk(finding) {
77
+ const owasp = finding.owasp;
78
+ if (!owasp || !OWASP_AGENTIC_TOP_10[owasp]) return finding;
79
+
80
+ const risk = OWASP_AGENTIC_TOP_10[owasp];
81
+ finding.agenticRisk = {
82
+ id: risk.id,
83
+ title: risk.title,
84
+ description: risk.description,
85
+ };
86
+ return finding;
87
+ }
88
+
89
+ /**
90
+ * Get OWASP Agentic Top 10 summary across all findings.
91
+ * @param {object[]} findings
92
+ * @returns {{ risks: object[], coverage: number }}
93
+ */
94
+ export function getAgenticSummary(findings) {
95
+ const counts = {};
96
+ for (const f of findings) {
97
+ const owasp = f.owasp;
98
+ if (owasp && OWASP_AGENTIC_TOP_10[owasp]) {
99
+ counts[owasp] = (counts[owasp] || 0) + 1;
100
+ }
101
+ }
102
+
103
+ const risks = Object.entries(OWASP_AGENTIC_TOP_10).map(([id, info]) => ({
104
+ ...info,
105
+ findingCount: counts[id] || 0,
106
+ status: counts[id] ? 'flagged' : 'clear',
107
+ }));
108
+
109
+ const flagged = risks.filter(r => r.findingCount > 0).length;
110
+
111
+ return {
112
+ risks,
113
+ flagged,
114
+ total: 10,
115
+ coverage: `${flagged}/10`,
116
+ };
117
+ }
118
+
53
119
  // =============================================================================
54
120
  // PUBLIC API
55
121
  // =============================================================================
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ship-safe",
3
- "version": "6.3.0",
4
- "description": "AI-powered multi-agent security platform. 18 agents scan 80+ attack classes with LLM-powered deep analysis. Red team your code before attackers do.",
3
+ "version": "7.0.0",
4
+ "description": "AI-powered multi-agent security platform. 19 agents scan 80+ attack classes with LLM-powered deep analysis, OWASP Agentic AI Top 10 mapping, memory poisoning detection, and live advisory feeds. Red team your code before attackers do.",
5
5
  "main": "cli/index.js",
6
6
  "bin": {
7
7
  "ship-safe": "cli/bin/ship-safe.js"