@mcp-shark/mcp-shark 1.5.13 → 1.6.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.
Files changed (100) hide show
  1. package/README.md +396 -59
  2. package/bin/mcp-shark.js +146 -52
  3. package/core/cli/AutoFixEngine.js +93 -0
  4. package/core/cli/ConfigScanner.js +193 -0
  5. package/core/cli/DataLoader.js +200 -0
  6. package/core/cli/DeclarativeRuleEngine.js +363 -0
  7. package/core/cli/DoctorCommand.js +218 -0
  8. package/core/cli/FixHandlers.js +222 -0
  9. package/core/cli/HtmlReportGenerator.js +203 -0
  10. package/core/cli/IdeConfigPaths.js +175 -0
  11. package/core/cli/ListCommand.js +224 -0
  12. package/core/cli/LockCommand.js +164 -0
  13. package/core/cli/LockDiffEngine.js +152 -0
  14. package/core/cli/RuleRegistryConfig.js +131 -0
  15. package/core/cli/ScanCommand.js +244 -0
  16. package/core/cli/ScanService.js +200 -0
  17. package/core/cli/SecretDetector.js +92 -0
  18. package/core/cli/SharkScoreCalculator.js +109 -0
  19. package/core/cli/ToolClassifications.js +51 -0
  20. package/core/cli/ToxicFlowAnalyzer.js +212 -0
  21. package/core/cli/UpdateCommand.js +188 -0
  22. package/core/cli/WalkthroughGenerator.js +195 -0
  23. package/core/cli/WatchCommand.js +129 -0
  24. package/core/cli/YamlRuleEngine.js +197 -0
  25. package/core/cli/data/rule-packs/agentic-security-2026.json +180 -0
  26. package/core/cli/data/rule-packs/general-security.json +173 -0
  27. package/core/cli/data/rule-packs/owasp-mcp-2026.json +244 -0
  28. package/core/cli/data/rule-packs/toxic-flow-heuristics.json +21 -0
  29. package/core/cli/data/rule-sources.json +5 -0
  30. package/core/cli/data/secret-patterns.json +18 -0
  31. package/core/cli/data/tool-classifications.json +111 -0
  32. package/core/cli/data/toxic-flow-rules.json +47 -0
  33. package/core/cli/index.js +23 -0
  34. package/core/cli/output/Banner.js +52 -0
  35. package/core/cli/output/Formatter.js +183 -0
  36. package/core/cli/output/JsonFormatter.js +106 -0
  37. package/core/cli/output/index.js +16 -0
  38. package/core/cli/secureRegistryFetch.js +157 -0
  39. package/core/cli/symbols.js +16 -0
  40. package/core/container/DependencyContainer.js +4 -1
  41. package/core/repositories/PacketRepository.js +16 -0
  42. package/core/services/AuditService.js +2 -0
  43. package/core/services/security/StaticRulesService.js +69 -13
  44. package/core/services/security/TrafficAnalysisService.js +19 -1
  45. package/core/services/security/TrafficToxicFlowService.js +154 -0
  46. package/core/services/security/index.js +2 -1
  47. package/core/services/security/rules/index.js +25 -59
  48. package/core/services/security/rules/scans/configPermissions.js +91 -0
  49. package/core/services/security/rules/scans/duplicateToolNames.js +85 -0
  50. package/core/services/security/rules/scans/insecureTransport.js +148 -0
  51. package/core/services/security/rules/scans/missingContainment.js +123 -0
  52. package/core/services/security/rules/scans/shellEnvInjection.js +101 -0
  53. package/core/services/security/rules/scans/unsafeDefaults.js +99 -0
  54. package/core/services/security/toolsListFromTrafficParser.js +70 -0
  55. package/core/tui/App.js +144 -0
  56. package/core/tui/FindingsPanel.js +115 -0
  57. package/core/tui/FixPanel.js +132 -0
  58. package/core/tui/Header.js +51 -0
  59. package/core/tui/HelpBar.js +42 -0
  60. package/core/tui/ServersPanel.js +109 -0
  61. package/core/tui/ToxicFlowsPanel.js +100 -0
  62. package/core/tui/h.js +8 -0
  63. package/core/tui/index.js +11 -0
  64. package/core/tui/render.js +22 -0
  65. package/package.json +24 -16
  66. package/ui/dist/assets/{index-CiCSDYf-.js → index-Buah4fNS.js} +32 -32
  67. package/ui/dist/index.html +1 -1
  68. package/ui/server/controllers/RequestController.js +9 -1
  69. package/ui/server/controllers/SecurityFindingsController.js +46 -1
  70. package/ui/server/routes/requests.js +8 -1
  71. package/ui/server/routes/security.js +5 -1
  72. package/ui/server/setup.js +2 -0
  73. package/ui/server/swagger/paths/components.js +55 -0
  74. package/ui/server/swagger/paths/securityTrafficFlows.js +59 -0
  75. package/ui/server/swagger/paths.js +2 -0
  76. package/ui/server/swagger/swagger.js +5 -1
  77. package/ui/src/Security.jsx +10 -0
  78. package/ui/src/components/Security/ScannerContent.jsx +30 -1
  79. package/ui/src/components/Security/TrafficToxicFlowsPanel.jsx +253 -0
  80. package/ui/src/components/Security/securityApi.js +15 -0
  81. package/ui/src/components/Security/useSecurity.js +60 -3
  82. package/core/services/security/rules/scans/agentic01GoalHijack.js +0 -130
  83. package/core/services/security/rules/scans/agentic02ToolMisuse.js +0 -129
  84. package/core/services/security/rules/scans/agentic03IdentityAbuse.js +0 -130
  85. package/core/services/security/rules/scans/agentic04SupplyChain.js +0 -130
  86. package/core/services/security/rules/scans/agentic06MemoryPoisoning.js +0 -130
  87. package/core/services/security/rules/scans/agentic07InsecureCommunication.js +0 -135
  88. package/core/services/security/rules/scans/agentic08CascadingFailures.js +0 -135
  89. package/core/services/security/rules/scans/agentic09TrustExploitation.js +0 -135
  90. package/core/services/security/rules/scans/agentic10RogueAgent.js +0 -130
  91. package/core/services/security/rules/scans/hardcodedSecrets.js +0 -130
  92. package/core/services/security/rules/scans/mcp01TokenMismanagement.js +0 -127
  93. package/core/services/security/rules/scans/mcp02ScopeCreep.js +0 -130
  94. package/core/services/security/rules/scans/mcp03ToolPoisoning.js +0 -132
  95. package/core/services/security/rules/scans/mcp04SupplyChain.js +0 -131
  96. package/core/services/security/rules/scans/mcp06PromptInjection.js +0 -200
  97. package/core/services/security/rules/scans/mcp07InsufficientAuth.js +0 -130
  98. package/core/services/security/rules/scans/mcp08LackAudit.js +0 -129
  99. package/core/services/security/rules/scans/mcp09ShadowServers.js +0 -129
  100. package/core/services/security/rules/scans/mcp10ContextInjection.js +0 -130
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Shark Score Calculator
3
+ * Transparent, reproducible scoring formula for MCP security posture
4
+ *
5
+ * Formula: Shark Score = max(0, 100 - Σ finding_weights)
6
+ *
7
+ * Finding weights:
8
+ * CRITICAL: 25 points
9
+ * HIGH: 15 points
10
+ * MEDIUM: 5 points
11
+ * LOW: 2 points
12
+ * Toxic flow (HIGH): 10 points
13
+ * Toxic flow (MEDIUM): 5 points
14
+ *
15
+ * Grades:
16
+ * 90-100 A Excellent
17
+ * 75-89 B Good
18
+ * 50-74 C Needs work
19
+ * 25-49 D Poor
20
+ * 0-24 F Critical risk
21
+ */
22
+
23
+ const FINDING_WEIGHTS = {
24
+ critical: 25,
25
+ high: 15,
26
+ medium: 5,
27
+ low: 2,
28
+ };
29
+
30
+ const TOXIC_FLOW_WEIGHTS = {
31
+ high: 10,
32
+ medium: 5,
33
+ low: 2,
34
+ };
35
+
36
+ const GRADES = [
37
+ { min: 90, grade: 'A', label: 'Excellent' },
38
+ { min: 75, grade: 'B', label: 'Good' },
39
+ { min: 50, grade: 'C', label: 'Needs work' },
40
+ { min: 25, grade: 'D', label: 'Poor' },
41
+ { min: 0, grade: 'F', label: 'Critical risk' },
42
+ ];
43
+
44
+ /**
45
+ * Calculate the Shark Score from findings and toxic flows
46
+ * @param {Array} findings - Array of security findings with severity
47
+ * @param {Array} toxicFlows - Array of toxic flow results with risk level
48
+ * @returns {{ score: number, grade: string, label: string, breakdown: object }}
49
+ */
50
+ export function calculateSharkScore(findings, toxicFlows = []) {
51
+ const confirmedFindings = findings.filter((f) => f.confidence !== 'possible');
52
+
53
+ const findingDeductions = confirmedFindings.reduce((sum, finding) => {
54
+ const severity = (finding.severity || finding.risk_level || '').toLowerCase();
55
+ const weight = FINDING_WEIGHTS[severity] || 0;
56
+ return sum + weight;
57
+ }, 0);
58
+
59
+ const flowDeductions = toxicFlows.reduce((sum, flow) => {
60
+ const risk = (flow.risk || '').toLowerCase();
61
+ const weight = TOXIC_FLOW_WEIGHTS[risk] || 0;
62
+ return sum + weight;
63
+ }, 0);
64
+
65
+ const totalDeductions = findingDeductions + flowDeductions;
66
+ const score = Math.max(0, 100 - totalDeductions);
67
+ const gradeInfo = getGrade(score);
68
+
69
+ return {
70
+ score,
71
+ grade: gradeInfo.grade,
72
+ label: gradeInfo.label,
73
+ breakdown: {
74
+ findingDeductions,
75
+ flowDeductions,
76
+ totalDeductions,
77
+ confirmedCount: confirmedFindings.length,
78
+ flowCount: toxicFlows.length,
79
+ },
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Get grade from score
85
+ */
86
+ function getGrade(score) {
87
+ for (const g of GRADES) {
88
+ if (score >= g.min) {
89
+ return g;
90
+ }
91
+ }
92
+ return GRADES[GRADES.length - 1];
93
+ }
94
+
95
+ /**
96
+ * Count findings by severity
97
+ * @param {Array} findings
98
+ * @returns {{ critical: number, high: number, medium: number, low: number }}
99
+ */
100
+ export function countBySeverity(findings) {
101
+ const counts = { critical: 0, high: 0, medium: 0, low: 0 };
102
+ for (const f of findings) {
103
+ const severity = (f.severity || f.risk_level || '').toLowerCase();
104
+ if (counts[severity] !== undefined) {
105
+ counts[severity] += 1;
106
+ }
107
+ }
108
+ return counts;
109
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Tool Classification Database
3
+ * Maps known MCP server tools to their capability categories.
4
+ *
5
+ * Built-in classifications loaded from data/tool-classifications.json.
6
+ * User overrides merged from .mcp-shark/classifications.yaml.
7
+ *
8
+ * Categories:
9
+ * ingests_untrusted - reads external/public data
10
+ * writes_code - modifies source code, pushes commits
11
+ * sends_external - sends data to external endpoints
12
+ * reads_secrets - accesses sensitive local data
13
+ * modifies_infra - changes infrastructure state
14
+ */
15
+ import { loadBuiltinJson, loadUserYamlMap } from './DataLoader.js';
16
+
17
+ const BUILTIN = loadBuiltinJson('tool-classifications.json');
18
+ const USER_OVERRIDES = loadUserYamlMap('classifications.yaml');
19
+
20
+ const UNSAFE_CLASSIFICATION_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
21
+
22
+ export const TOOL_CLASSIFICATIONS = mergeClassifications(BUILTIN, USER_OVERRIDES);
23
+
24
+ /**
25
+ * Deep-merge user overrides into built-in classifications.
26
+ * User entries for existing servers extend (not replace) the tool map.
27
+ * New servers are added wholesale.
28
+ * @param {Record<string, Record<string, string>>} builtin
29
+ * @param {Record<string, unknown>} overrides
30
+ */
31
+ export function mergeClassifications(builtin, overrides) {
32
+ const merged = { ...builtin };
33
+ for (const [server, tools] of Object.entries(overrides)) {
34
+ if (UNSAFE_CLASSIFICATION_KEYS.has(server)) {
35
+ continue;
36
+ }
37
+ if (!tools || typeof tools !== 'object' || Array.isArray(tools)) {
38
+ continue;
39
+ }
40
+ const safeTools = { ...tools };
41
+ for (const k of UNSAFE_CLASSIFICATION_KEYS) {
42
+ delete safeTools[k];
43
+ }
44
+ if (merged[server]) {
45
+ merged[server] = { ...merged[server], ...safeTools };
46
+ } else {
47
+ merged[server] = { ...safeTools };
48
+ }
49
+ }
50
+ return merged;
51
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Toxic Flow Analyzer
3
+ * Detects dangerous cross-server composition risks by classifying
4
+ * tools by capability and identifying pairs that create attack paths
5
+ * through the shared LLM context window.
6
+ *
7
+ * Flow rules are loaded from data/toxic-flow-rules.json (built-in),
8
+ * optional toxic_flow_rules inside rule-pack JSON under data/rule-packs/ and
9
+ * .mcp-shark/rule-packs/, and user overrides from .mcp-shark/flows.yaml.
10
+ *
11
+ * Catalog references: §1.1, §1.2, §1.3, §1.7, §1.10, §1.12, §1.13, §1.14
12
+ */
13
+ import { join } from 'node:path';
14
+ import { loadBuiltinJson, loadToxicFlowRulesFromPacksDir, loadUserYamlList } from './DataLoader.js';
15
+ import { TOOL_CLASSIFICATIONS } from './ToolClassifications.js';
16
+
17
+ const BUILTIN_PACKS_DIR = join(import.meta.dirname, 'data', 'rule-packs');
18
+ const USER_PACKS_DIR = join(process.cwd(), '.mcp-shark', 'rule-packs');
19
+
20
+ const BUILTIN_FLOWS = loadBuiltinJson('toxic-flow-rules.json');
21
+ const PACK_FLOWS_BUILTIN = loadToxicFlowRulesFromPacksDir(BUILTIN_PACKS_DIR);
22
+ const PACK_FLOWS_USER = loadToxicFlowRulesFromPacksDir(USER_PACKS_DIR);
23
+ const USER_FLOWS = loadUserYamlList('flows.yaml');
24
+ const TOXIC_FLOW_RULES = [
25
+ ...BUILTIN_FLOWS,
26
+ ...PACK_FLOWS_BUILTIN,
27
+ ...PACK_FLOWS_USER,
28
+ ...USER_FLOWS,
29
+ ];
30
+
31
+ /**
32
+ * Classify a tool's capability based on known classifications and heuristics
33
+ */
34
+ function classifyTool(serverName, toolName) {
35
+ const serverClassifications = TOOL_CLASSIFICATIONS[serverName];
36
+ if (serverClassifications?.[toolName]) {
37
+ return serverClassifications[toolName];
38
+ }
39
+
40
+ const nameLower = (toolName || '').toLowerCase();
41
+
42
+ if (
43
+ matchesPatterns(nameLower, ['send_message', 'post_message', 'send_email', 'send_notification'])
44
+ ) {
45
+ return 'sends_external';
46
+ }
47
+ if (
48
+ matchesPatterns(nameLower, [
49
+ 'write_file',
50
+ 'create_file',
51
+ 'push',
52
+ 'commit',
53
+ 'create_pr',
54
+ 'create_pull',
55
+ ])
56
+ ) {
57
+ return 'writes_code';
58
+ }
59
+ if (
60
+ matchesPatterns(nameLower, ['read_file', 'get_file', 'list_dir', 'get_config', 'get_secret'])
61
+ ) {
62
+ return 'reads_secrets';
63
+ }
64
+ if (
65
+ matchesPatterns(nameLower, [
66
+ 'get_issue',
67
+ 'get_ticket',
68
+ 'list_messages',
69
+ 'search',
70
+ 'fetch',
71
+ 'scrape',
72
+ ])
73
+ ) {
74
+ return 'ingests_untrusted';
75
+ }
76
+ if (matchesPatterns(nameLower, ['deploy', 'kubectl', 'docker', 'transfer', 'scale', 'restart'])) {
77
+ return 'modifies_infra';
78
+ }
79
+
80
+ return null;
81
+ }
82
+
83
+ /**
84
+ * Check if a name matches any of the patterns
85
+ */
86
+ function matchesPatterns(name, patterns) {
87
+ for (const pattern of patterns) {
88
+ if (name.includes(pattern)) {
89
+ return true;
90
+ }
91
+ }
92
+ return false;
93
+ }
94
+
95
+ /**
96
+ * Classify all tools in a server and return capabilities
97
+ */
98
+ function classifyServer(server) {
99
+ const capabilities = new Set();
100
+ const classifiedTools = {};
101
+
102
+ const toolNames = extractToolNames(server);
103
+
104
+ for (const toolName of toolNames) {
105
+ const capability = classifyTool(server.name, toolName);
106
+ if (capability) {
107
+ capabilities.add(capability);
108
+ classifiedTools[toolName] = capability;
109
+ }
110
+ }
111
+
112
+ return { capabilities: [...capabilities], classifiedTools };
113
+ }
114
+
115
+ /**
116
+ * Extract tool names from a server definition
117
+ */
118
+ function extractToolNames(server) {
119
+ if (Array.isArray(server.tools)) {
120
+ return server.tools.map((t) => t.name || t);
121
+ }
122
+ if (server.tools && typeof server.tools === 'object') {
123
+ return Object.keys(server.tools);
124
+ }
125
+ return [];
126
+ }
127
+
128
+ /**
129
+ * Interpolate a scenario template string with source/target context.
130
+ * Replaces {source}, {target}, {source_ide}, {target_ide} placeholders.
131
+ */
132
+ function interpolateScenario(template, src, tgt) {
133
+ return template
134
+ .replace(/\{source_ide\}/g, src.ide || 'IDE')
135
+ .replace(/\{target_ide\}/g, tgt.ide || 'IDE')
136
+ .replace(/\{source\}/g, src.name)
137
+ .replace(/\{target\}/g, tgt.name);
138
+ }
139
+
140
+ /**
141
+ * Analyze toxic flows across all servers
142
+ * @param {Array} servers - Flat list of server objects with { name, ide, config, tools }
143
+ * @returns {Array} Array of toxic flow results
144
+ */
145
+ export function analyzeToxicFlows(servers) {
146
+ const classifiedServers = servers.map((server) => ({
147
+ ...server,
148
+ ...classifyServer(server),
149
+ }));
150
+
151
+ const flows = [];
152
+
153
+ for (const rule of TOXIC_FLOW_RULES) {
154
+ const sources = classifiedServers.filter((s) => s.capabilities.includes(rule.source));
155
+ const targets = classifiedServers.filter((s) => s.capabilities.includes(rule.target));
156
+
157
+ for (const src of sources) {
158
+ for (const tgt of targets) {
159
+ if (src.name === tgt.name) {
160
+ continue;
161
+ }
162
+
163
+ flows.push({
164
+ source: src.name,
165
+ sourceIde: src.ide,
166
+ target: tgt.name,
167
+ targetIde: tgt.ide,
168
+ risk: rule.risk,
169
+ title: rule.title,
170
+ scenario: interpolateScenario(rule.scenario, src, tgt),
171
+ catalog: rule.catalog,
172
+ owasp: rule.owasp,
173
+ sourceCapability: rule.source,
174
+ targetCapability: rule.target,
175
+ });
176
+ }
177
+ }
178
+ }
179
+
180
+ return deduplicateFlows(flows);
181
+ }
182
+
183
+ /**
184
+ * Remove duplicate flows (same source, target, IDE pair, capability pair, and title; keep highest risk)
185
+ */
186
+ function deduplicateFlows(flows) {
187
+ const seen = new Map();
188
+ for (const flow of flows) {
189
+ const key = [
190
+ flow.source,
191
+ flow.sourceIde,
192
+ flow.target,
193
+ flow.targetIde,
194
+ flow.sourceCapability,
195
+ flow.targetCapability,
196
+ flow.title,
197
+ ].join('\u2192');
198
+ const existing = seen.get(key);
199
+ if (!existing || riskLevel(flow.risk) > riskLevel(existing.risk)) {
200
+ seen.set(key, flow);
201
+ }
202
+ }
203
+ return [...seen.values()];
204
+ }
205
+
206
+ /**
207
+ * Convert risk string to numeric level for comparison
208
+ */
209
+ function riskLevel(risk) {
210
+ const levels = { HIGH: 3, MEDIUM: 2, LOW: 1 };
211
+ return levels[risk] || 0;
212
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Update Rules Command
3
+ * Downloads latest rule packs from a remote registry and caches locally.
4
+ *
5
+ * Security: HTTPS-only URLs (unless MCP_SHARK_INSECURE_HTTP_REGISTRY=1), manual redirects
6
+ * with re-validation, response size limits, safe pack filenames, optional SHA-256 in manifest.
7
+ */
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import kleur from 'kleur';
11
+ import { resetStaticRulesCache } from '#core/services/security/StaticRulesService.js';
12
+ import { resolveRuleRegistryConfig } from './RuleRegistryConfig.js';
13
+ import {
14
+ assertAllowedRegistryUrl,
15
+ assertSafePackId,
16
+ assertSha256,
17
+ fetchJsonSecure,
18
+ fetchUtf8Secure,
19
+ } from './secureRegistryFetch.js';
20
+ import { S } from './symbols.js';
21
+
22
+ const MANIFEST_MAX_BYTES = 2 * 1024 * 1024;
23
+ const PACK_MAX_BYTES = 25 * 1024 * 1024;
24
+
25
+ /**
26
+ * Execute the update-rules command.
27
+ * @param {object} options
28
+ * @param {string} [options.source] - Custom manifest URL (CLI override)
29
+ * @param {boolean} [options.quiet] - Minimal output (e.g. before scan)
30
+ * @returns {Promise<number>} Exit code (0 success, 1 failure — for CI)
31
+ */
32
+ export async function executeUpdateRules(options = {}) {
33
+ const { registryUrl, cacheDir } = resolveRuleRegistryConfig({
34
+ overrideUrl: options.source,
35
+ });
36
+
37
+ const quiet = Boolean(options.quiet);
38
+
39
+ if (!quiet) {
40
+ console.log('');
41
+ console.log(` ${kleur.bold('mcp-shark update-rules')}`);
42
+ console.log(kleur.dim(' ─────────────────────────────────────'));
43
+ console.log('');
44
+ console.log(` ${S.info} Registry: ${kleur.dim(registryUrl)}`);
45
+ console.log('');
46
+ }
47
+
48
+ let manifest;
49
+ try {
50
+ manifest = await fetchJsonSecure(registryUrl, MANIFEST_MAX_BYTES);
51
+ } catch (err) {
52
+ const msg = `Failed to fetch manifest: ${err.message}`;
53
+ if (quiet) {
54
+ console.log(` ${S.warn} ${msg} (using built-in / cached packs)`);
55
+ } else {
56
+ console.log(` ${S.fail} ${msg}`);
57
+ console.log(` ${S.info} Rule packs are unchanged. Built-in packs still active.`);
58
+ console.log('');
59
+ }
60
+ return 1;
61
+ }
62
+
63
+ if (!manifest.packs || !Array.isArray(manifest.packs)) {
64
+ if (!quiet) {
65
+ console.log(` ${S.fail} Invalid manifest format (missing "packs" array)`);
66
+ console.log('');
67
+ }
68
+ return 1;
69
+ }
70
+
71
+ ensureDir(cacheDir);
72
+
73
+ let updated = 0;
74
+ let skipped = 0;
75
+ let hadPackFailure = false;
76
+
77
+ for (const packRef of manifest.packs) {
78
+ if (!packRef || typeof packRef.id !== 'string' || typeof packRef.url !== 'string') {
79
+ if (!quiet) {
80
+ console.log(` ${S.warn} Skipping invalid pack entry`);
81
+ }
82
+ continue;
83
+ }
84
+
85
+ try {
86
+ assertSafePackId(packRef.id);
87
+ assertAllowedRegistryUrl(packRef.url);
88
+ } catch (err) {
89
+ if (!quiet) {
90
+ console.log(` ${S.warn} ${packRef.id}: ${err.message}`);
91
+ }
92
+ continue;
93
+ }
94
+
95
+ const localPath = join(cacheDir, `${packRef.id}.json`);
96
+ const localVersion = readLocalVersion(localPath);
97
+
98
+ if (localVersion && packRef.version && localVersion === packRef.version) {
99
+ if (!quiet) {
100
+ console.log(` ${S.dot} ${packRef.id} v${packRef.version} ${kleur.dim('up to date')}`);
101
+ }
102
+ skipped++;
103
+ continue;
104
+ }
105
+
106
+ try {
107
+ const packText = await fetchUtf8Secure(packRef.url, PACK_MAX_BYTES);
108
+ assertSha256(packRef.sha256, packText);
109
+ const packData = JSON.parse(packText);
110
+ const toxicExtra = Array.isArray(packData.toxic_flow_rules) ? packData.toxic_flow_rules : [];
111
+ if (!packData.schema_version || !Array.isArray(packData.rules)) {
112
+ if (!quiet) {
113
+ console.log(` ${S.warn} ${packRef.id}: invalid pack format, skipped`);
114
+ }
115
+ hadPackFailure = true;
116
+ continue;
117
+ }
118
+ if (packData.rules.length === 0 && toxicExtra.length === 0) {
119
+ if (!quiet) {
120
+ console.log(` ${S.warn} ${packRef.id}: empty rules and toxic_flow_rules, skipped`);
121
+ }
122
+ hadPackFailure = true;
123
+ continue;
124
+ }
125
+
126
+ const packToWrite = { ...packData };
127
+ if (packRef.version && packToWrite.version == null) {
128
+ packToWrite.version = packRef.version;
129
+ }
130
+
131
+ writeFileSync(localPath, JSON.stringify(packToWrite, null, 2), 'utf-8');
132
+ resetStaticRulesCache();
133
+ const verb = localVersion ? 'updated' : 'downloaded';
134
+ const ruleCount = packData.rules.length;
135
+ const toxicCount = toxicExtra.length;
136
+ if (!quiet) {
137
+ const parts = [];
138
+ if (ruleCount > 0) {
139
+ parts.push(`${ruleCount} rules`);
140
+ }
141
+ if (toxicCount > 0) {
142
+ parts.push(`${toxicCount} toxic-flow rule${toxicCount !== 1 ? 's' : ''}`);
143
+ }
144
+ console.log(
145
+ ` ${S.pass} ${packRef.id} v${packRef.version} ${kleur.green(verb)} (${parts.join(', ')})`
146
+ );
147
+ }
148
+ updated++;
149
+ } catch (err) {
150
+ if (!quiet) {
151
+ console.log(` ${S.fail} ${packRef.id}: ${err.message}`);
152
+ }
153
+ hadPackFailure = true;
154
+ }
155
+ }
156
+
157
+ if (!quiet) {
158
+ console.log('');
159
+ if (updated > 0) {
160
+ console.log(` ${S.pass} ${updated} pack(s) updated, ${skipped} up to date`);
161
+ } else {
162
+ console.log(` ${S.info} All ${skipped} pack(s) up to date`);
163
+ }
164
+ console.log(` ${S.info} Cache: ${kleur.dim(cacheDir)}`);
165
+ console.log('');
166
+ }
167
+
168
+ return hadPackFailure ? 1 : 0;
169
+ }
170
+
171
+ function readLocalVersion(filePath) {
172
+ if (!existsSync(filePath)) {
173
+ return null;
174
+ }
175
+ try {
176
+ const content = readFileSync(filePath, 'utf-8');
177
+ const pack = JSON.parse(content);
178
+ return pack.version || null;
179
+ } catch (_err) {
180
+ return null;
181
+ }
182
+ }
183
+
184
+ function ensureDir(dirPath) {
185
+ if (!existsSync(dirPath)) {
186
+ mkdirSync(dirPath, { recursive: true });
187
+ }
188
+ }