@mcp-shark/mcp-shark 1.5.13 → 1.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +482 -56
- package/bin/mcp-shark.js +146 -52
- package/core/cli/AutoFixEngine.js +93 -0
- package/core/cli/ConfigScanner.js +193 -0
- package/core/cli/DataLoader.js +200 -0
- package/core/cli/DeclarativeRuleEngine.js +363 -0
- package/core/cli/DoctorCommand.js +218 -0
- package/core/cli/FixHandlers.js +222 -0
- package/core/cli/HtmlReportGenerator.js +203 -0
- package/core/cli/IdeConfigPaths.js +175 -0
- package/core/cli/ListCommand.js +255 -0
- package/core/cli/LockCommand.js +164 -0
- package/core/cli/LockDiffEngine.js +152 -0
- package/core/cli/RuleRegistryConfig.js +131 -0
- package/core/cli/ScanCommand.js +244 -0
- package/core/cli/ScanService.js +200 -0
- package/core/cli/SecretDetector.js +92 -0
- package/core/cli/SharkScoreCalculator.js +109 -0
- package/core/cli/ToolClassifications.js +51 -0
- package/core/cli/ToxicFlowAnalyzer.js +212 -0
- package/core/cli/UpdateCommand.js +188 -0
- package/core/cli/WalkthroughGenerator.js +195 -0
- package/core/cli/WatchCommand.js +129 -0
- package/core/cli/YamlRuleEngine.js +197 -0
- package/core/cli/data/rule-packs/aauth-visibility.json +117 -0
- package/core/cli/data/rule-packs/agentic-security-2026.json +180 -0
- package/core/cli/data/rule-packs/general-security.json +173 -0
- package/core/cli/data/rule-packs/owasp-mcp-2026.json +244 -0
- package/core/cli/data/rule-packs/toxic-flow-heuristics.json +21 -0
- package/core/cli/data/rule-sources.json +5 -0
- package/core/cli/data/secret-patterns.json +18 -0
- package/core/cli/data/tool-classifications.json +111 -0
- package/core/cli/data/toxic-flow-rules.json +47 -0
- package/core/cli/index.js +23 -0
- package/core/cli/output/Banner.js +52 -0
- package/core/cli/output/Formatter.js +183 -0
- package/core/cli/output/JsonFormatter.js +106 -0
- package/core/cli/output/index.js +16 -0
- package/core/cli/secureRegistryFetch.js +157 -0
- package/core/cli/symbols.js +16 -0
- package/core/configs/environment.js +3 -1
- package/core/configs/index.js +3 -64
- package/core/container/DependencyContainer.js +4 -1
- package/core/mcp-server/index.js +4 -1
- package/core/mcp-server/server/external/all.js +10 -3
- package/core/mcp-server/server/external/config.js +62 -5
- package/core/models/RequestFilters.js +3 -0
- package/core/repositories/PacketRepository.js +16 -0
- package/core/services/AuditService.js +2 -0
- package/core/services/ConfigService.js +9 -1
- package/core/services/ConfigTransformService.js +34 -2
- package/core/services/RequestService.js +58 -5
- package/core/services/ServerManagementService.js +59 -4
- package/core/services/security/StaticRulesService.js +69 -13
- package/core/services/security/TrafficAnalysisService.js +19 -1
- package/core/services/security/TrafficToxicFlowService.js +154 -0
- package/core/services/security/aauthGraph.js +199 -0
- package/core/services/security/aauthParser.js +274 -0
- package/core/services/security/aauthSelfTest.js +346 -0
- package/core/services/security/index.js +2 -1
- package/core/services/security/rules/index.js +25 -59
- package/core/services/security/rules/scans/configPermissions.js +91 -0
- package/core/services/security/rules/scans/duplicateToolNames.js +85 -0
- package/core/services/security/rules/scans/insecureTransport.js +148 -0
- package/core/services/security/rules/scans/missingContainment.js +123 -0
- package/core/services/security/rules/scans/shellEnvInjection.js +101 -0
- package/core/services/security/rules/scans/unsafeDefaults.js +99 -0
- package/core/services/security/toolsListFromTrafficParser.js +70 -0
- package/core/tui/App.js +144 -0
- package/core/tui/FindingsPanel.js +115 -0
- package/core/tui/FixPanel.js +132 -0
- package/core/tui/Header.js +51 -0
- package/core/tui/HelpBar.js +42 -0
- package/core/tui/ServersPanel.js +109 -0
- package/core/tui/ToxicFlowsPanel.js +100 -0
- package/core/tui/h.js +8 -0
- package/core/tui/index.js +11 -0
- package/core/tui/render.js +22 -0
- package/package.json +24 -16
- package/ui/dist/assets/index-D6zDrtMV.js +81 -0
- package/ui/dist/index.html +1 -1
- package/ui/server/controllers/AauthController.js +279 -0
- package/ui/server/controllers/RequestController.js +12 -1
- package/ui/server/controllers/SecurityFindingsController.js +46 -1
- package/ui/server/routes/aauth.js +18 -0
- package/ui/server/routes/requests.js +8 -1
- package/ui/server/routes/security.js +5 -1
- package/ui/server/setup.js +224 -6
- package/ui/server/swagger/paths/components.js +55 -0
- package/ui/server/swagger/paths/securityTrafficFlows.js +59 -0
- package/ui/server/swagger/paths.js +2 -2
- package/ui/server/swagger/swagger.js +5 -2
- package/ui/server.js +1 -1
- package/ui/src/App.jsx +26 -52
- package/ui/src/PacketFilters.jsx +31 -1
- package/ui/src/PacketList.jsx +2 -2
- package/ui/src/Security.jsx +10 -0
- package/ui/src/TabNavigation.jsx +8 -0
- package/ui/src/components/AAuthBadge.jsx +92 -0
- package/ui/src/components/AauthExplorer/AauthExplorerGraph.jsx +231 -0
- package/ui/src/components/AauthExplorer/AauthExplorerView.jsx +387 -0
- package/ui/src/components/AauthExplorer/NodeDetailPanel.jsx +272 -0
- package/ui/src/components/App/ActionMenu.jsx +4 -31
- package/ui/src/components/App/ApiDocsButton.jsx +0 -1
- package/ui/src/components/App/ShutdownButton.jsx +0 -1
- package/ui/src/components/App/useAppState.js +19 -26
- package/ui/src/components/DetailsTab/AAuthIdentitySection.jsx +119 -0
- package/ui/src/components/DetailsTab/RequestDetailsSection.jsx +2 -0
- package/ui/src/components/DetailsTab/ResponseDetailsSection.jsx +2 -0
- package/ui/src/components/DetectedPathsList.jsx +1 -5
- package/ui/src/components/FileInput.jsx +0 -1
- package/ui/src/components/PacketFilters/AAuthPostureFilter.jsx +81 -0
- package/ui/src/components/RequestRow/RequestRowMain.jsx +7 -1
- package/ui/src/components/Security/AAuthPosturePanel.jsx +360 -0
- package/ui/src/components/Security/ScannerContent.jsx +33 -1
- package/ui/src/components/Security/TrafficToxicFlowsPanel.jsx +253 -0
- package/ui/src/components/Security/securityApi.js +15 -0
- package/ui/src/components/Security/useSecurity.js +60 -3
- package/ui/src/components/ServerControl.jsx +0 -1
- package/ui/src/components/TabNavigation/DesktopTabs.jsx +0 -11
- package/ui/src/components/TabNavigationIcons.jsx +5 -0
- package/ui/src/components/ViewModeTabs.jsx +0 -1
- package/ui/src/utils/animations.js +26 -9
- package/core/services/security/rules/scans/agentic01GoalHijack.js +0 -130
- package/core/services/security/rules/scans/agentic02ToolMisuse.js +0 -129
- package/core/services/security/rules/scans/agentic03IdentityAbuse.js +0 -130
- package/core/services/security/rules/scans/agentic04SupplyChain.js +0 -130
- package/core/services/security/rules/scans/agentic06MemoryPoisoning.js +0 -130
- package/core/services/security/rules/scans/agentic07InsecureCommunication.js +0 -135
- package/core/services/security/rules/scans/agentic08CascadingFailures.js +0 -135
- package/core/services/security/rules/scans/agentic09TrustExploitation.js +0 -135
- package/core/services/security/rules/scans/agentic10RogueAgent.js +0 -130
- package/core/services/security/rules/scans/hardcodedSecrets.js +0 -130
- package/core/services/security/rules/scans/mcp01TokenMismanagement.js +0 -127
- package/core/services/security/rules/scans/mcp02ScopeCreep.js +0 -130
- package/core/services/security/rules/scans/mcp03ToolPoisoning.js +0 -132
- package/core/services/security/rules/scans/mcp04SupplyChain.js +0 -131
- package/core/services/security/rules/scans/mcp06PromptInjection.js +0 -200
- package/core/services/security/rules/scans/mcp07InsufficientAuth.js +0 -130
- package/core/services/security/rules/scans/mcp08LackAudit.js +0 -129
- package/core/services/security/rules/scans/mcp09ShadowServers.js +0 -129
- package/core/services/security/rules/scans/mcp10ContextInjection.js +0 -130
- package/ui/dist/assets/index-CiCSDYf-.js +0 -97
- package/ui/server/routes/help.js +0 -44
- package/ui/server/swagger/paths/help.js +0 -82
- package/ui/src/HelpGuide/HelpGuideContent.jsx +0 -118
- package/ui/src/HelpGuide/HelpGuideFooter.jsx +0 -59
- package/ui/src/HelpGuide/HelpGuideHeader.jsx +0 -57
- package/ui/src/HelpGuide.jsx +0 -78
- package/ui/src/IntroTour.jsx +0 -154
- package/ui/src/components/App/HelpButton.jsx +0 -90
- package/ui/src/components/TourOverlay.jsx +0 -117
- package/ui/src/components/TourTooltip/TourTooltipButtons.jsx +0 -120
- package/ui/src/components/TourTooltip/TourTooltipHeader.jsx +0 -71
- package/ui/src/components/TourTooltip/TourTooltipIcons.jsx +0 -54
- package/ui/src/components/TourTooltip/useTooltipPosition.js +0 -135
- package/ui/src/components/TourTooltip.jsx +0 -91
- package/ui/src/config/tourSteps.jsx +0 -140
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lock Diff Engine
|
|
3
|
+
* Computes and renders differences between lockfile and current MCP state.
|
|
4
|
+
* Used by both `lock --verify` and `diff` commands.
|
|
5
|
+
*/
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import kleur from 'kleur';
|
|
8
|
+
import { S } from './symbols.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Hash a tool definition using SHA-256
|
|
12
|
+
*/
|
|
13
|
+
export function hashToolDefinition(tool) {
|
|
14
|
+
const canonical = JSON.stringify({
|
|
15
|
+
name: tool.name,
|
|
16
|
+
description: tool.description,
|
|
17
|
+
inputSchema: tool.inputSchema || tool.parameters,
|
|
18
|
+
});
|
|
19
|
+
return createHash('sha256').update(canonical).digest('hex');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Count parameters in a tool definition
|
|
24
|
+
*/
|
|
25
|
+
export function countParameters(tool) {
|
|
26
|
+
const schema = tool.inputSchema || tool.parameters || {};
|
|
27
|
+
const properties = schema.properties || {};
|
|
28
|
+
return Object.keys(properties).length;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Normalize server.tools (array or name→definition map) to an array of tool objects
|
|
33
|
+
*/
|
|
34
|
+
export function normalizeToolsList(tools) {
|
|
35
|
+
if (!tools) {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
if (Array.isArray(tools)) {
|
|
39
|
+
return tools;
|
|
40
|
+
}
|
|
41
|
+
if (typeof tools === 'object') {
|
|
42
|
+
return Object.entries(tools).map(([name, def]) => {
|
|
43
|
+
if (def && typeof def === 'object' && !Array.isArray(def)) {
|
|
44
|
+
return { ...def, name: def.name || name };
|
|
45
|
+
}
|
|
46
|
+
return typeof def === 'string' ? { name, description: def } : { name };
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Compute diff between lockfile and current state
|
|
54
|
+
* @param {object} lockData - Parsed lockfile data
|
|
55
|
+
* @param {Array} currentServers - Current servers from ConfigScanner
|
|
56
|
+
* @returns {Array} List of change objects
|
|
57
|
+
*/
|
|
58
|
+
export function computeDiff(lockData, currentServers) {
|
|
59
|
+
const changes = [];
|
|
60
|
+
|
|
61
|
+
for (const server of currentServers) {
|
|
62
|
+
const locked = lockData.servers[server.name];
|
|
63
|
+
if (!locked) {
|
|
64
|
+
changes.push({ type: 'added_server', server: server.name, ide: server.ide });
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const tools = normalizeToolsList(server.tools);
|
|
69
|
+
for (const tool of tools) {
|
|
70
|
+
const toolObj = typeof tool === 'string' ? { name: tool } : tool;
|
|
71
|
+
const toolName = toolObj.name || 'unknown';
|
|
72
|
+
const lockedTool = locked.tools[toolName];
|
|
73
|
+
|
|
74
|
+
if (!lockedTool) {
|
|
75
|
+
changes.push({ type: 'added_tool', server: server.name, tool: toolName });
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const currentHash = `sha256:${hashToolDefinition(toolObj)}`;
|
|
80
|
+
if (currentHash !== lockedTool.hash) {
|
|
81
|
+
changes.push({ type: 'changed_tool', server: server.name, tool: toolName });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
checkRemovedTools(locked, tools, server.name, changes);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
checkRemovedServers(lockData, currentServers, changes);
|
|
89
|
+
|
|
90
|
+
return changes;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Check for tools that were in lockfile but removed from current state
|
|
95
|
+
*/
|
|
96
|
+
function checkRemovedTools(locked, tools, serverName, changes) {
|
|
97
|
+
for (const lockedToolName of Object.keys(locked.tools)) {
|
|
98
|
+
const stillExists = tools.some((t) => {
|
|
99
|
+
const name = typeof t === 'string' ? t : t.name;
|
|
100
|
+
return name === lockedToolName;
|
|
101
|
+
});
|
|
102
|
+
if (!stillExists) {
|
|
103
|
+
changes.push({ type: 'removed_tool', server: serverName, tool: lockedToolName });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Check for servers that were in lockfile but removed
|
|
110
|
+
*/
|
|
111
|
+
function checkRemovedServers(lockData, currentServers, changes) {
|
|
112
|
+
for (const lockedServerName of Object.keys(lockData.servers)) {
|
|
113
|
+
const stillExists = currentServers.some((s) => s.name === lockedServerName);
|
|
114
|
+
if (!stillExists) {
|
|
115
|
+
changes.push({ type: 'removed_server', server: lockedServerName });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Render diff changes to terminal
|
|
122
|
+
*/
|
|
123
|
+
export function renderDiff(changes) {
|
|
124
|
+
if (changes.length === 0) {
|
|
125
|
+
console.log(` ${kleur.green(S.pass)} No changes detected`);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
console.log('');
|
|
130
|
+
for (const change of changes) {
|
|
131
|
+
if (change.type === 'added_server') {
|
|
132
|
+
console.log(
|
|
133
|
+
` ${kleur.green('+')} Server added: ${kleur.bold(change.server)} (${change.ide})`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
if (change.type === 'removed_server') {
|
|
137
|
+
console.log(` ${kleur.red('-')} Server removed: ${kleur.bold(change.server)}`);
|
|
138
|
+
}
|
|
139
|
+
if (change.type === 'added_tool') {
|
|
140
|
+
console.log(` ${kleur.green('+')} Tool added: ${change.server}/${kleur.bold(change.tool)}`);
|
|
141
|
+
}
|
|
142
|
+
if (change.type === 'removed_tool') {
|
|
143
|
+
console.log(` ${kleur.red('-')} Tool removed: ${change.server}/${kleur.bold(change.tool)}`);
|
|
144
|
+
}
|
|
145
|
+
if (change.type === 'changed_tool') {
|
|
146
|
+
console.log(
|
|
147
|
+
` ${kleur.yellow('~')} Tool changed: ${change.server}/${kleur.bold(change.tool)} ${kleur.yellow(S.warn)}`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
console.log('');
|
|
152
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves rule registry URL and update policy without hardcoding beyond bootstrap defaults.
|
|
3
|
+
*
|
|
4
|
+
* Precedence (highest first):
|
|
5
|
+
* 1. CLI --source (passed in as overrideUrl)
|
|
6
|
+
* 2. MCP_SHARK_RULE_REGISTRY
|
|
7
|
+
* 3. .mcp-shark/rule-registry.json (project cwd)
|
|
8
|
+
* 4. ~/.config/mcp-shark/rule-registry.json (XDG-style)
|
|
9
|
+
* 5. Built-in rule-sources.json
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { loadBuiltinJson } from './DataLoader.js';
|
|
15
|
+
|
|
16
|
+
const BUILTIN = loadBuiltinJson('rule-sources.json');
|
|
17
|
+
|
|
18
|
+
function projectRegistryPath() {
|
|
19
|
+
return join(process.cwd(), '.mcp-shark', 'rule-registry.json');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function userRegistryPath() {
|
|
23
|
+
const base = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
|
|
24
|
+
return join(base, 'mcp-shark', 'rule-registry.json');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {object} raw
|
|
29
|
+
* @returns {object|null}
|
|
30
|
+
*/
|
|
31
|
+
function parseRegistryFile(rawPath) {
|
|
32
|
+
if (!existsSync(rawPath)) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(readFileSync(rawPath, 'utf-8'));
|
|
37
|
+
return typeof parsed === 'object' && parsed !== null ? parsed : null;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Reject cache_dir path traversal and absolute paths.
|
|
45
|
+
* @param {string} rel
|
|
46
|
+
*/
|
|
47
|
+
function assertSafeRelativeCacheDir(rel) {
|
|
48
|
+
if (typeof rel !== 'string' || rel.length === 0) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (rel.includes('..') || rel.startsWith('/') || /^[A-Za-z]:[\\/]/.test(rel)) {
|
|
52
|
+
throw new Error('rule-registry.json cache_dir must be a relative path without ..');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {object} [opts]
|
|
58
|
+
* @param {string} [opts.overrideUrl] - from CLI --source
|
|
59
|
+
* @returns {{
|
|
60
|
+
* registryUrl: string,
|
|
61
|
+
* cacheDir: string,
|
|
62
|
+
* autoUpdate: boolean,
|
|
63
|
+
* autoUpdateMaxAgeHours: number
|
|
64
|
+
* }}
|
|
65
|
+
*/
|
|
66
|
+
export function resolveRuleRegistryConfig(opts = {}) {
|
|
67
|
+
const projectFile = parseRegistryFile(projectRegistryPath());
|
|
68
|
+
const userFile = parseRegistryFile(userRegistryPath());
|
|
69
|
+
|
|
70
|
+
const merged = {
|
|
71
|
+
...BUILTIN,
|
|
72
|
+
...(userFile || {}),
|
|
73
|
+
...(projectFile || {}),
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
if (merged.cache_dir) {
|
|
77
|
+
assertSafeRelativeCacheDir(merged.cache_dir);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const cliUrl = opts.overrideUrl && String(opts.overrideUrl).trim();
|
|
81
|
+
const envUrl = process.env.MCP_SHARK_RULE_REGISTRY?.trim();
|
|
82
|
+
const registryUrl = cliUrl || envUrl || merged.registry_url || BUILTIN.registry_url;
|
|
83
|
+
|
|
84
|
+
const cacheDirRel = merged.cache_dir || BUILTIN.cache_dir;
|
|
85
|
+
assertSafeRelativeCacheDir(cacheDirRel);
|
|
86
|
+
const cacheDir = join(process.cwd(), cacheDirRel);
|
|
87
|
+
|
|
88
|
+
const autoUpdate = merged.auto_update === true;
|
|
89
|
+
const autoUpdateMaxAgeHours = Number(merged.auto_update_max_age_hours);
|
|
90
|
+
const builtinMax = Number(BUILTIN.default_auto_update_max_age_hours);
|
|
91
|
+
const maxAge =
|
|
92
|
+
Number.isFinite(autoUpdateMaxAgeHours) && autoUpdateMaxAgeHours > 0
|
|
93
|
+
? autoUpdateMaxAgeHours
|
|
94
|
+
: Number.isFinite(builtinMax) && builtinMax > 0
|
|
95
|
+
? builtinMax
|
|
96
|
+
: 168;
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
registryUrl,
|
|
100
|
+
cacheDir,
|
|
101
|
+
autoUpdate,
|
|
102
|
+
autoUpdateMaxAgeHours: maxAge,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* True when cache has no packs or newest pack file is older than maxAgeHours.
|
|
108
|
+
* @param {string} cacheDir
|
|
109
|
+
* @param {number} maxAgeHours
|
|
110
|
+
*/
|
|
111
|
+
export function isRuleCacheStale(cacheDir, maxAgeHours) {
|
|
112
|
+
if (!existsSync(cacheDir)) {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const files = readdirSync(cacheDir).filter((f) => f.endsWith('.json') && f !== '.meta.json');
|
|
117
|
+
if (files.length === 0) {
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let newest = 0;
|
|
122
|
+
for (const f of files) {
|
|
123
|
+
const m = statSync(join(cacheDir, f)).mtimeMs;
|
|
124
|
+
if (m > newest) {
|
|
125
|
+
newest = m;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const maxAgeMs = maxAgeHours * 60 * 60 * 1000;
|
|
130
|
+
return Date.now() - newest > maxAgeMs;
|
|
131
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scan Command
|
|
3
|
+
* Wires ScanService results to CLI output with flag support
|
|
4
|
+
*/
|
|
5
|
+
import { confirm } from '@clack/prompts';
|
|
6
|
+
import { applyFixes, renderFixResults } from './AutoFixEngine.js';
|
|
7
|
+
import { generateHtmlReport } from './HtmlReportGenerator.js';
|
|
8
|
+
import { isRuleCacheStale, resolveRuleRegistryConfig } from './RuleRegistryConfig.js';
|
|
9
|
+
import { runScan } from './ScanService.js';
|
|
10
|
+
import { calculateSharkScore } from './SharkScoreCalculator.js';
|
|
11
|
+
import { executeUpdateRules } from './UpdateCommand.js';
|
|
12
|
+
import { formatWalkthrough, generateWalkthroughs } from './WalkthroughGenerator.js';
|
|
13
|
+
import {
|
|
14
|
+
displayScanBanner,
|
|
15
|
+
formatAsJson,
|
|
16
|
+
formatAsSarif,
|
|
17
|
+
formatCleanServers,
|
|
18
|
+
formatIdeDiscovery,
|
|
19
|
+
formatNextSteps,
|
|
20
|
+
formatServerFindings,
|
|
21
|
+
formatSharkScore,
|
|
22
|
+
formatSummaryCounts,
|
|
23
|
+
formatTiming,
|
|
24
|
+
formatToxicFlows,
|
|
25
|
+
} from './output/index.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Execute the scan command
|
|
29
|
+
* @param {object} options - CLI options from commander
|
|
30
|
+
* @param {boolean} [options.fix] - Auto-fix fixable issues
|
|
31
|
+
* @param {boolean} [options.walkthrough] - Show attack chain narratives
|
|
32
|
+
* @param {boolean} [options.ci] - CI mode (exit code 1 on critical/high)
|
|
33
|
+
* @param {string} [options.format] - Output format: 'json' | 'sarif' | 'terminal'
|
|
34
|
+
* @param {boolean} [options.strict] - Count advisory findings in score
|
|
35
|
+
* @param {string} [options.ide] - Filter to specific IDE
|
|
36
|
+
* @param {boolean} [options.yes] - Skip confirmation for --fix
|
|
37
|
+
* @param {string} [options.output] - Output file path (for html format)
|
|
38
|
+
* @param {string} [options.rules] - Path to custom YAML rules directory
|
|
39
|
+
* @param {boolean} [options.refreshRules] - Fetch registry packs before scan
|
|
40
|
+
*/
|
|
41
|
+
export async function executeScan(options = {}) {
|
|
42
|
+
const refreshExit = await maybeRefreshRulesBeforeScan(options);
|
|
43
|
+
if (refreshExit !== 0) {
|
|
44
|
+
return refreshExit;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const format = (options.format || 'terminal').toLowerCase();
|
|
48
|
+
|
|
49
|
+
const scanResult = runScan({
|
|
50
|
+
ide: options.ide,
|
|
51
|
+
strict: options.strict,
|
|
52
|
+
rulesPath: options.rules,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (format === 'json') {
|
|
56
|
+
console.log(formatAsJson(buildJsonOutput(scanResult)));
|
|
57
|
+
return exitWithCode(scanResult, options.ci);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (format === 'sarif') {
|
|
61
|
+
console.log(formatAsSarif(buildJsonOutput(scanResult)));
|
|
62
|
+
return exitWithCode(scanResult, options.ci);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (format === 'html') {
|
|
66
|
+
generateHtmlReport(scanResult, options.output);
|
|
67
|
+
return exitWithCode(scanResult, options.ci);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
renderTerminalOutput(scanResult, options);
|
|
71
|
+
|
|
72
|
+
if (options.fix) {
|
|
73
|
+
await executeAutoFix(scanResult, {
|
|
74
|
+
undo: options.undo,
|
|
75
|
+
skipConfirm: options.yes || options.ci,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return exitWithCode(scanResult, options.ci);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Optional network: only when --refresh-rules or auto_update + stale cache.
|
|
84
|
+
* --refresh-rules: failure exits non-zero. Background auto-update: fail-open (scan continues).
|
|
85
|
+
*/
|
|
86
|
+
async function maybeRefreshRulesBeforeScan(options) {
|
|
87
|
+
const config = resolveRuleRegistryConfig({});
|
|
88
|
+
if (options.refreshRules) {
|
|
89
|
+
return executeUpdateRules({ quiet: true });
|
|
90
|
+
}
|
|
91
|
+
if (config.autoUpdate && isRuleCacheStale(config.cacheDir, config.autoUpdateMaxAgeHours)) {
|
|
92
|
+
await executeUpdateRules({ quiet: true });
|
|
93
|
+
}
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Render the full terminal output
|
|
99
|
+
*/
|
|
100
|
+
function renderTerminalOutput(scanResult, options) {
|
|
101
|
+
displayScanBanner();
|
|
102
|
+
console.log(formatIdeDiscovery(scanResult.ideResults));
|
|
103
|
+
console.log('');
|
|
104
|
+
|
|
105
|
+
renderFindings(scanResult);
|
|
106
|
+
renderToxicFlows(scanResult);
|
|
107
|
+
renderScore(scanResult);
|
|
108
|
+
|
|
109
|
+
if (options.walkthrough && scanResult.toxicFlows.length > 0) {
|
|
110
|
+
renderWalkthroughs(scanResult.toxicFlows);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const hasFixable = scanResult.findings.some((f) => f.fixable);
|
|
114
|
+
const hasFlows = scanResult.toxicFlows.length > 0;
|
|
115
|
+
console.log(formatNextSteps(hasFixable, hasFlows));
|
|
116
|
+
console.log('');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Render findings grouped by server
|
|
121
|
+
*/
|
|
122
|
+
function renderFindings(scanResult) {
|
|
123
|
+
for (const [serverName, findings] of Object.entries(scanResult.findingsByServer)) {
|
|
124
|
+
const server = scanResult.servers.find((s) => s.name === serverName);
|
|
125
|
+
const ideName = server ? server.ide : 'unknown';
|
|
126
|
+
console.log(formatServerFindings(serverName, ideName, findings));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (scanResult.cleanServers.length > 0) {
|
|
130
|
+
console.log(formatCleanServers(scanResult.cleanServers));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Render toxic flows section
|
|
136
|
+
*/
|
|
137
|
+
function renderToxicFlows(scanResult) {
|
|
138
|
+
if (scanResult.toxicFlows.length > 0) {
|
|
139
|
+
console.log(formatToxicFlows(scanResult.toxicFlows));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Render score and summary
|
|
145
|
+
*/
|
|
146
|
+
function renderScore(scanResult) {
|
|
147
|
+
console.log('');
|
|
148
|
+
console.log(formatSharkScore(scanResult.scoreResult));
|
|
149
|
+
console.log(formatSummaryCounts(scanResult.severityCounts, scanResult.toxicFlows.length));
|
|
150
|
+
console.log(
|
|
151
|
+
formatTiming(
|
|
152
|
+
scanResult.elapsedMs,
|
|
153
|
+
scanResult.serverCount,
|
|
154
|
+
scanResult.ruleCount,
|
|
155
|
+
scanResult.totalToolCount
|
|
156
|
+
)
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Render attack walkthroughs
|
|
162
|
+
*/
|
|
163
|
+
function renderWalkthroughs(toxicFlows) {
|
|
164
|
+
const walkthroughs = generateWalkthroughs(toxicFlows);
|
|
165
|
+
for (const walkthrough of walkthroughs) {
|
|
166
|
+
console.log(formatWalkthrough(walkthrough));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Execute auto-fix (or undo) with optional interactive confirmation
|
|
172
|
+
*/
|
|
173
|
+
async function executeAutoFix(scanResult, fixOptions = {}) {
|
|
174
|
+
const fixable = scanResult.findings.filter((f) => f.fixable);
|
|
175
|
+
|
|
176
|
+
if (fixable.length === 0 && !fixOptions.undo) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!fixOptions.skipConfirm && !fixOptions.undo) {
|
|
181
|
+
const shouldProceed = await confirm({
|
|
182
|
+
message: `Apply ${fixable.length} auto-fixes? (backups will be created)`,
|
|
183
|
+
});
|
|
184
|
+
if (!shouldProceed || typeof shouldProceed === 'symbol') {
|
|
185
|
+
console.log(' Fix cancelled.');
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const scoreBefore = scanResult.scoreResult.score;
|
|
191
|
+
const fixResult = applyFixes(scanResult.findings, { undo: fixOptions.undo });
|
|
192
|
+
|
|
193
|
+
const remainingFindings = scanResult.findings.filter(
|
|
194
|
+
(f) => !fixResult.fixed.some((fx) => fx.finding === f)
|
|
195
|
+
);
|
|
196
|
+
const scoreAfterResult = calculateSharkScore(remainingFindings, scanResult.toxicFlows);
|
|
197
|
+
const scoreAfter = scoreAfterResult.score;
|
|
198
|
+
|
|
199
|
+
renderFixResults(fixResult, scoreBefore, scoreAfter);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Build structured JSON output
|
|
204
|
+
*/
|
|
205
|
+
function buildJsonOutput(scanResult) {
|
|
206
|
+
return {
|
|
207
|
+
version: '1.0.0',
|
|
208
|
+
timestamp: new Date().toISOString(),
|
|
209
|
+
score: scanResult.scoreResult,
|
|
210
|
+
findings: scanResult.findings,
|
|
211
|
+
toxicFlows: scanResult.toxicFlows,
|
|
212
|
+
servers: scanResult.servers.map((s) => ({
|
|
213
|
+
name: s.name,
|
|
214
|
+
ide: s.ide,
|
|
215
|
+
configPath: s.configPath,
|
|
216
|
+
})),
|
|
217
|
+
summary: {
|
|
218
|
+
serverCount: scanResult.serverCount,
|
|
219
|
+
ruleCount: scanResult.ruleCount,
|
|
220
|
+
toolCount: scanResult.totalToolCount,
|
|
221
|
+
elapsedMs: scanResult.elapsedMs,
|
|
222
|
+
severityCounts: scanResult.severityCounts,
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Exit with appropriate code for CI mode
|
|
229
|
+
*/
|
|
230
|
+
function exitWithCode(scanResult, ciMode) {
|
|
231
|
+
if (!ciMode) {
|
|
232
|
+
return 0;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const hasCriticalOrHigh = scanResult.findings.some((f) => {
|
|
236
|
+
const severity = (f.severity || f.risk_level || '').toLowerCase();
|
|
237
|
+
return severity === 'critical' || severity === 'high';
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
if (hasCriticalOrHigh) {
|
|
241
|
+
return 1;
|
|
242
|
+
}
|
|
243
|
+
return 0;
|
|
244
|
+
}
|