@coffeerunhobby/mcp-ha-connect 2.2.1 → 2.3.1

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 (281) hide show
  1. package/.env.example +1 -1
  2. package/README.md +11 -2
  3. package/dist/omadaClient/client.d.ts +6 -1
  4. package/dist/omadaClient/client.js +12 -1
  5. package/dist/omadaClient/client.js.map +1 -1
  6. package/dist/omadaClient/diagnose.d.ts +68 -0
  7. package/dist/omadaClient/diagnose.js +164 -0
  8. package/dist/omadaClient/diagnose.js.map +1 -0
  9. package/dist/omadaClient/index.d.ts +6 -0
  10. package/dist/omadaClient/index.js +15 -0
  11. package/dist/omadaClient/index.js.map +1 -1
  12. package/dist/omadaClient/macGroups.d.ts +58 -0
  13. package/dist/omadaClient/macGroups.js +124 -0
  14. package/dist/omadaClient/macGroups.js.map +1 -0
  15. package/dist/tools/infra/actions.d.ts +1 -1
  16. package/dist/tools/infra/actions.js +1 -1
  17. package/dist/tools/omada/index.js +6 -0
  18. package/dist/tools/omada/index.js.map +1 -1
  19. package/dist/tools/omada/macGroups.d.ts +19 -0
  20. package/dist/tools/omada/macGroups.js +77 -0
  21. package/dist/tools/omada/macGroups.js.map +1 -0
  22. package/dist/tools/omada/namespace.js +8 -0
  23. package/dist/tools/omada/namespace.js.map +1 -1
  24. package/dist/version.d.ts +1 -1
  25. package/dist/version.js +1 -1
  26. package/package.json +5 -1
  27. package/.codex/review-rules.md +0 -16
  28. package/.dockerignore +0 -8
  29. package/.gitattributes +0 -30
  30. package/.github/workflows/ci.yml +0 -193
  31. package/AGENTS.md +0 -389
  32. package/Dockerfile +0 -50
  33. package/docker-compose.yml +0 -23
  34. package/docs/CHANGELOG.md +0 -341
  35. package/docs/COVERAGE.md +0 -219
  36. package/docs/QUICK_START.md +0 -240
  37. package/eslint.config.js +0 -33
  38. package/n8n/ai-agent-safety-monitor.json +0 -355
  39. package/openapi.json +0 -1259
  40. package/scripts/check-version-sync.mjs +0 -69
  41. package/scripts/generate-jwt.ts +0 -121
  42. package/src/config.ts +0 -440
  43. package/src/haClient/automations.ts +0 -261
  44. package/src/haClient/calendars.ts +0 -94
  45. package/src/haClient/config.ts +0 -52
  46. package/src/haClient/devices.ts +0 -653
  47. package/src/haClient/entities.ts +0 -128
  48. package/src/haClient/events.ts +0 -689
  49. package/src/haClient/history.ts +0 -73
  50. package/src/haClient/index.ts +0 -336
  51. package/src/haClient/request.ts +0 -142
  52. package/src/haClient/services.ts +0 -45
  53. package/src/haClient/states.ts +0 -60
  54. package/src/haClient/updates.ts +0 -58
  55. package/src/haClient/wsCommand.ts +0 -44
  56. package/src/index.ts +0 -164
  57. package/src/localAI/index.ts +0 -88
  58. package/src/localAI/prompts.ts +0 -50
  59. package/src/localAI/providers/base.ts +0 -120
  60. package/src/localAI/providers/ollama.ts +0 -78
  61. package/src/localAI/providers/openai.ts +0 -86
  62. package/src/localAI/types.ts +0 -67
  63. package/src/omadaClient/auth.ts +0 -178
  64. package/src/omadaClient/client.ts +0 -337
  65. package/src/omadaClient/device.ts +0 -118
  66. package/src/omadaClient/index.ts +0 -310
  67. package/src/omadaClient/network.ts +0 -284
  68. package/src/omadaClient/request.ts +0 -365
  69. package/src/omadaClient/security.ts +0 -51
  70. package/src/omadaClient/site.ts +0 -344
  71. package/src/ownerQuestions/service.ts +0 -536
  72. package/src/permissions/index.ts +0 -143
  73. package/src/resources/index.ts +0 -213
  74. package/src/server/auth.ts +0 -175
  75. package/src/server/chatFace.ts +0 -789
  76. package/src/server/chatSlice.ts +0 -57
  77. package/src/server/common.ts +0 -170
  78. package/src/server/eventSubscription.ts +0 -343
  79. package/src/server/http.ts +0 -626
  80. package/src/server/instructions.ts +0 -97
  81. package/src/server/rateLimiter.ts +0 -227
  82. package/src/server/stdio.ts +0 -41
  83. package/src/server/stream.ts +0 -205
  84. package/src/tools/ai/analyzeSensors.ts +0 -33
  85. package/src/tools/ai/index.ts +0 -20
  86. package/src/tools/common.ts +0 -442
  87. package/src/tools/homeassistant/askOwner.ts +0 -133
  88. package/src/tools/homeassistant/automationControls.ts +0 -66
  89. package/src/tools/homeassistant/calendar.ts +0 -97
  90. package/src/tools/homeassistant/callService.ts +0 -25
  91. package/src/tools/homeassistant/controlClimate.ts +0 -68
  92. package/src/tools/homeassistant/controlCover.ts +0 -48
  93. package/src/tools/homeassistant/controlFan.ts +0 -44
  94. package/src/tools/homeassistant/controlLight.ts +0 -38
  95. package/src/tools/homeassistant/controlMediaPlayer.ts +0 -50
  96. package/src/tools/homeassistant/createAutomation.ts +0 -35
  97. package/src/tools/homeassistant/deleteAutomation.ts +0 -24
  98. package/src/tools/homeassistant/entityAction.ts +0 -35
  99. package/src/tools/homeassistant/entityGuard.ts +0 -28
  100. package/src/tools/homeassistant/getAllSensors.ts +0 -46
  101. package/src/tools/homeassistant/getAutomationTrace.ts +0 -30
  102. package/src/tools/homeassistant/getDomainSummary.ts +0 -29
  103. package/src/tools/homeassistant/getEntitiesByDomain.ts +0 -56
  104. package/src/tools/homeassistant/getHistory.ts +0 -24
  105. package/src/tools/homeassistant/getState.ts +0 -24
  106. package/src/tools/homeassistant/getStates.ts +0 -46
  107. package/src/tools/homeassistant/getVersion.ts +0 -21
  108. package/src/tools/homeassistant/index.ts +0 -121
  109. package/src/tools/homeassistant/listAutomations.ts +0 -35
  110. package/src/tools/homeassistant/listEntities.ts +0 -44
  111. package/src/tools/homeassistant/listPersons.ts +0 -46
  112. package/src/tools/homeassistant/sceneAndScript.ts +0 -53
  113. package/src/tools/homeassistant/searchEntities.ts +0 -58
  114. package/src/tools/homeassistant/sendNotification.ts +0 -179
  115. package/src/tools/homeassistant/systemTools.ts +0 -52
  116. package/src/tools/homeassistant/triggerAutomation.ts +0 -24
  117. package/src/tools/index.ts +0 -78
  118. package/src/tools/infra/actions.ts +0 -95
  119. package/src/tools/infra/index.ts +0 -138
  120. package/src/tools/omada/blockClient.ts +0 -28
  121. package/src/tools/omada/cyclePoePort.ts +0 -32
  122. package/src/tools/omada/deleteClient.ts +0 -27
  123. package/src/tools/omada/disableClientRateLimit.ts +0 -23
  124. package/src/tools/omada/getClient.ts +0 -14
  125. package/src/tools/omada/getDevice.ts +0 -14
  126. package/src/tools/omada/getFirewallSetting.ts +0 -14
  127. package/src/tools/omada/getInternetInfo.ts +0 -14
  128. package/src/tools/omada/getLanNetworkList.ts +0 -14
  129. package/src/tools/omada/getLanProfileList.ts +0 -14
  130. package/src/tools/omada/getPortForwardingStatus.ts +0 -26
  131. package/src/tools/omada/getRateLimitProfiles.ts +0 -14
  132. package/src/tools/omada/getSiteNtpStatus.ts +0 -14
  133. package/src/tools/omada/getSsidDetail.ts +0 -22
  134. package/src/tools/omada/getSsidList.ts +0 -21
  135. package/src/tools/omada/getSwitchStackDetail.ts +0 -14
  136. package/src/tools/omada/getThreatList.ts +0 -42
  137. package/src/tools/omada/getWlanGroupList.ts +0 -14
  138. package/src/tools/omada/graph.ts +0 -187
  139. package/src/tools/omada/index.ts +0 -153
  140. package/src/tools/omada/listBlockedClients.ts +0 -17
  141. package/src/tools/omada/listClients.ts +0 -14
  142. package/src/tools/omada/listClientsActivity.ts +0 -25
  143. package/src/tools/omada/listClientsPastConnections.ts +0 -44
  144. package/src/tools/omada/listDevices.ts +0 -14
  145. package/src/tools/omada/listDevicesStats.ts +0 -41
  146. package/src/tools/omada/listMostActiveClients.ts +0 -14
  147. package/src/tools/omada/listSites.ts +0 -18
  148. package/src/tools/omada/namespace.ts +0 -1164
  149. package/src/tools/omada/searchDevices.ts +0 -20
  150. package/src/tools/omada/setClientRateLimit.ts +0 -26
  151. package/src/tools/omada/setClientRateLimitProfile.ts +0 -25
  152. package/src/tools/omada/setSiteNtpServers.ts +0 -36
  153. package/src/tools/omada/setSsidEnabled.ts +0 -38
  154. package/src/tools/omada/unblockClient.ts +0 -26
  155. package/src/tools/types.ts +0 -11
  156. package/src/tools/utils/pagination-schema.ts +0 -27
  157. package/src/types/ai/index.ts +0 -11
  158. package/src/types/ai/types.ts +0 -67
  159. package/src/types/homeassistant/automations.ts +0 -43
  160. package/src/types/homeassistant/calendars.ts +0 -26
  161. package/src/types/homeassistant/config.ts +0 -14
  162. package/src/types/homeassistant/entities.ts +0 -14
  163. package/src/types/homeassistant/errors.ts +0 -33
  164. package/src/types/homeassistant/haConfig.ts +0 -7
  165. package/src/types/homeassistant/history.ts +0 -13
  166. package/src/types/homeassistant/index.ts +0 -46
  167. package/src/types/homeassistant/request.ts +0 -6
  168. package/src/types/homeassistant/serviceCall.ts +0 -18
  169. package/src/types/homeassistant/states.ts +0 -58
  170. package/src/types/homeassistant/updates.ts +0 -28
  171. package/src/types/index.ts +0 -12
  172. package/src/types/omada/activeClientInfo.ts +0 -17
  173. package/src/types/omada/clientActivity.ts +0 -32
  174. package/src/types/omada/clientBlockStatus.ts +0 -11
  175. package/src/types/omada/clientPastConnection.ts +0 -82
  176. package/src/types/omada/index.ts +0 -14
  177. package/src/types/omada/omadaApiResponse.ts +0 -5
  178. package/src/types/omada/omadaClientInfo.ts +0 -11
  179. package/src/types/omada/omadaDeviceInfo.ts +0 -11
  180. package/src/types/omada/omadaDeviceStats.ts +0 -31
  181. package/src/types/omada/omadaSiteSummary.ts +0 -5
  182. package/src/types/omada/oswStackDetail.ts +0 -11
  183. package/src/types/omada/paginatedResult.ts +0 -6
  184. package/src/types/omada/rateLimitProfile.ts +0 -58
  185. package/src/types/omada/threatInfo.ts +0 -38
  186. package/src/types/omada/tokenResult.ts +0 -6
  187. package/src/types.ts +0 -2
  188. package/src/utils/config-validations.ts +0 -68
  189. package/src/utils/jwt.ts +0 -112
  190. package/src/utils/logger.ts +0 -121
  191. package/src/utils/plainText.ts +0 -48
  192. package/src/utils/redact.ts +0 -59
  193. package/src/utils/sanitizeError.ts +0 -18
  194. package/src/utils/tlsDispatcher.ts +0 -69
  195. package/src/version.ts +0 -5
  196. package/tests/bdd/automation-creation.spec.ts +0 -214
  197. package/tests/bdd/automation-management.spec.ts +0 -267
  198. package/tests/bdd/calendar.spec.ts +0 -190
  199. package/tests/bdd/configuration.spec.ts +0 -283
  200. package/tests/bdd/device-control.spec.ts +0 -344
  201. package/tests/bdd/entity-listing.spec.ts +0 -208
  202. package/tests/bdd/entity-search.spec.ts +0 -183
  203. package/tests/bdd/history-retrieval.spec.ts +0 -195
  204. package/tests/bdd/notifications.spec.ts +0 -238
  205. package/tests/bdd/service-calls.spec.ts +0 -144
  206. package/tests/bdd/state-retrieval.spec.ts +0 -204
  207. package/tests/bench/hotpaths.bench.ts +0 -68
  208. package/tests/config.test.ts +0 -355
  209. package/tests/features/automation-creation.feature +0 -36
  210. package/tests/features/automation-management.feature +0 -54
  211. package/tests/features/calendar.feature +0 -37
  212. package/tests/features/configuration.feature +0 -70
  213. package/tests/features/device-control.feature +0 -100
  214. package/tests/features/entity-listing.feature +0 -51
  215. package/tests/features/entity-search.feature +0 -44
  216. package/tests/features/history-retrieval.feature +0 -38
  217. package/tests/features/notifications.feature +0 -37
  218. package/tests/features/service-calls.feature +0 -35
  219. package/tests/features/state-retrieval.feature +0 -37
  220. package/tests/fixtures/openapi-default-slice.json +0 -290
  221. package/tests/haClient/automationTrace.test.ts +0 -59
  222. package/tests/haClient/automations.test.ts +0 -149
  223. package/tests/haClient/config.test.ts +0 -117
  224. package/tests/haClient/entities.test.ts +0 -188
  225. package/tests/haClient/events.test.ts +0 -455
  226. package/tests/haClient/eventsTls.test.ts +0 -52
  227. package/tests/haClient/history.test.ts +0 -228
  228. package/tests/haClient/index.test.ts +0 -243
  229. package/tests/haClient/request.test.ts +0 -217
  230. package/tests/haClient/services.test.ts +0 -120
  231. package/tests/haClient/states.test.ts +0 -119
  232. package/tests/haClient/updates.test.ts +0 -193
  233. package/tests/index.test.ts +0 -160
  234. package/tests/integration/homeassistant.test.ts +0 -144
  235. package/tests/integration/omada.test.ts +0 -167
  236. package/tests/integration/server.test.ts +0 -151
  237. package/tests/localAI/index.test.ts +0 -177
  238. package/tests/localAI/ollama.test.ts +0 -187
  239. package/tests/localAI/openai.test.ts +0 -257
  240. package/tests/omadaClient/actions.test.ts +0 -161
  241. package/tests/omadaClient/clientBlocks.test.ts +0 -170
  242. package/tests/omadaClient/ntp.test.ts +0 -381
  243. package/tests/omadaClient/site.test.ts +0 -37
  244. package/tests/ownerQuestions/service.test.ts +0 -499
  245. package/tests/ownerQuestions/tools.test.ts +0 -186
  246. package/tests/permissions/index.test.ts +0 -217
  247. package/tests/resources/index.test.ts +0 -288
  248. package/tests/scripts/generateJwt.test.ts +0 -44
  249. package/tests/security/accessControl.test.ts +0 -223
  250. package/tests/security/authentication.test.ts +0 -117
  251. package/tests/security/configuration.test.ts +0 -170
  252. package/tests/security/cryptoTransport.test.ts +0 -172
  253. package/tests/security/dependencies.test.ts +0 -152
  254. package/tests/security/injection.test.ts +0 -117
  255. package/tests/security/insecureDesign.test.ts +0 -121
  256. package/tests/security/logging.test.ts +0 -131
  257. package/tests/security/resourceConsumption.test.ts +0 -142
  258. package/tests/server/auth.test.ts +0 -249
  259. package/tests/server/chatFace.test.ts +0 -320
  260. package/tests/server/common.test.ts +0 -84
  261. package/tests/server/http.test.ts +0 -561
  262. package/tests/server/instructions.test.ts +0 -152
  263. package/tests/server/memoizeJsonSchema.test.ts +0 -71
  264. package/tests/server/stdio.test.ts +0 -99
  265. package/tests/server/stream.test.ts +0 -285
  266. package/tests/server/toolVisibility.http.test.ts +0 -111
  267. package/tests/server/toolVisibility.test.ts +0 -129
  268. package/tests/tools/ai/analyzeSensors.test.ts +0 -227
  269. package/tests/tools/common.test.ts +0 -123
  270. package/tests/tools/homeassistant/handlers.test.ts +0 -872
  271. package/tests/tools/infra/invokeAction.test.ts +0 -280
  272. package/tests/tools/omada/graph.test.ts +0 -385
  273. package/tests/tools/omada/handlers.test.ts +0 -897
  274. package/tests/tools/omada/namespace.test.ts +0 -483
  275. package/tests/tools/registry.test.ts +0 -168
  276. package/tests/utils/config-validations.test.ts +0 -352
  277. package/tests/utils/jwt.test.ts +0 -115
  278. package/tests/utils/logger.test.ts +0 -327
  279. package/tests/utils/redact.test.ts +0 -105
  280. package/tsconfig.json +0 -24
  281. package/vitest.config.ts +0 -38
@@ -1,170 +0,0 @@
1
- /**
2
- * SEC-CONFIG — Security Misconfiguration (OWASP API8:2023 / A05:2021).
3
- *
4
- * H5 Fail-closed bind: MCP_AUTH_METHOD=none must not be combined with a
5
- * non-loopback bind address — that exposes an unauthenticated server.
6
- * M1 docker-compose.yml must not hard-code a wildcard `MCP_HTTP_ALLOWED_ORIGINS=*`.
7
- * M3 DNS-rebinding Host validation is OPT-IN via MCP_HTTP_ALLOWED_HOSTS: OFF when
8
- * unset (no silent upgrade break), enforcing exactly the listed host[:port]
9
- * (plus loopback) when set. Default origins stay full scheme://host:port forms
10
- * so the separate Origin/CORS layer matches real Origin headers.
11
- * M10 Dockerfile must run as a non-root USER.
12
- * L4 HTTP responses must carry baseline security headers.
13
- * L7 .env.example must use the real var (MCP_AUTH_SECRET, not MCP_AUTH_TOKEN)
14
- * and ship secure defaults.
15
- */
16
-
17
- import { describe, it, expect } from 'vitest';
18
- import { readFileSync } from 'node:fs';
19
- import { resolve } from 'node:path';
20
- import { loadConfig } from '../../src/config.js';
21
- import { buildTransportSecurityOptions } from '../../src/server/stream.js';
22
- import { applySecurityHeaders } from '../../src/server/http.js';
23
-
24
- const repoRoot = process.cwd();
25
- const read = (rel: string): string => readFileSync(resolve(repoRoot, rel), 'utf8');
26
-
27
- const SECRET = 'test-secret-key-32chars-minimum!';
28
-
29
- describe('SEC-CONFIG H5: refuse auth=none on a non-loopback bind', () => {
30
- it('throws when MCP_AUTH_METHOD defaults to none and bind is 0.0.0.0', () => {
31
- expect(() => loadConfig({ MCP_HTTP_BIND_ADDR: '0.0.0.0' } as NodeJS.ProcessEnv)).toThrow(
32
- /none/i
33
- );
34
- });
35
-
36
- it('allows none with an explicit loopback bind', () => {
37
- const cfg = loadConfig({ MCP_HTTP_BIND_ADDR: '127.0.0.1' } as NodeJS.ProcessEnv);
38
- expect(cfg.authMethod).toBe('none');
39
- });
40
-
41
- it('allows none with the default (loopback) bind', () => {
42
- const cfg = loadConfig({} as NodeJS.ProcessEnv);
43
- expect(cfg.authMethod).toBe('none');
44
- expect(cfg.httpBindAddr).toBe('127.0.0.1');
45
- });
46
-
47
- it('allows bearer on a public bind when a strong secret is set', () => {
48
- const cfg = loadConfig({
49
- MCP_HTTP_BIND_ADDR: '0.0.0.0',
50
- MCP_AUTH_METHOD: 'bearer',
51
- MCP_AUTH_SECRET: SECRET,
52
- } as NodeJS.ProcessEnv);
53
- expect(cfg.authMethod).toBe('bearer');
54
- expect(cfg.httpBindAddr).toBe('0.0.0.0');
55
- });
56
- });
57
-
58
- describe('SEC-CONFIG M1: compose has no wildcard origin', () => {
59
- it('docker-compose.yml does not set MCP_HTTP_ALLOWED_ORIGINS=*', () => {
60
- const compose = read('docker-compose.yml');
61
- expect(compose).not.toMatch(/MCP_HTTP_ALLOWED_ORIGINS\s*[:=]\s*\*/);
62
- });
63
- });
64
-
65
- describe('SEC-CONFIG M3: opt-in DNS-rebinding Host validation', () => {
66
- const base = {
67
- MCP_HTTP_BIND_ADDR: '0.0.0.0',
68
- MCP_AUTH_METHOD: 'bearer',
69
- MCP_AUTH_SECRET: SECRET,
70
- } as NodeJS.ProcessEnv;
71
-
72
- it('default config origins are full scheme://host:port forms', () => {
73
- const cfg = loadConfig({} as NodeJS.ProcessEnv);
74
- expect(cfg.httpAllowedOrigins?.some((o) => /^https?:\/\/[^/]+:\d+$/.test(o))).toBe(true);
75
- });
76
-
77
- it('leaves Host validation OFF when MCP_HTTP_ALLOWED_HOSTS is unset (no upgrade break)', () => {
78
- const cfg = loadConfig({} as NodeJS.ProcessEnv);
79
- const opts = buildTransportSecurityOptions(cfg);
80
- expect(opts.enableDnsRebindingProtection).toBe(false);
81
- expect(opts.allowedHosts).toEqual([]);
82
- // Origin/CORS is enforced by the HTTP layer, not the transport.
83
- expect(opts.allowedOrigins).toEqual([]);
84
- });
85
-
86
- it('does NOT derive Host allowlist from CORS origins', () => {
87
- // Origins are client/CORS addresses; the server host is frequently different.
88
- // Setting origins alone must not turn on (or seed) Host validation.
89
- const cfg = loadConfig({
90
- ...base,
91
- MCP_HTTP_ALLOWED_ORIGINS: 'https://client.example.com:5678',
92
- } as NodeJS.ProcessEnv);
93
- const opts = buildTransportSecurityOptions(cfg);
94
- expect(opts.enableDnsRebindingProtection).toBe(false);
95
- expect(opts.allowedHosts).toEqual([]);
96
- });
97
-
98
- it('enforces exactly the configured hosts (plus loopback) when MCP_HTTP_ALLOWED_HOSTS is set', () => {
99
- const cfg = loadConfig({
100
- ...base,
101
- MCP_HTTP_ALLOWED_HOSTS: 'mcp.example.com,192.168.0.18:3000',
102
- } as NodeJS.ProcessEnv);
103
- const opts = buildTransportSecurityOptions(cfg);
104
- expect(opts.enableDnsRebindingProtection).toBe(true);
105
- expect(opts.allowedHosts).toContain('mcp.example.com');
106
- expect(opts.allowedHosts).toContain('192.168.0.18:3000');
107
- // Loopback/bind conveniences are always added so on-box access keeps working.
108
- expect(opts.allowedHosts).toContain('localhost:3000');
109
- expect(opts.allowedHosts).toContain('0.0.0.0:3000');
110
- // A host the operator did NOT list is absent → the SDK 403s it.
111
- expect(opts.allowedHosts).not.toContain('evil.attacker.test');
112
- });
113
-
114
- it('accepts a full-URL host entry and reduces it to host[:port] form', () => {
115
- const cfg = loadConfig({
116
- ...base,
117
- MCP_HTTP_ALLOWED_HOSTS: 'https://mcp.example.com',
118
- } as NodeJS.ProcessEnv);
119
- const opts = buildTransportSecurityOptions(cfg);
120
- expect(opts.allowedHosts).toContain('mcp.example.com');
121
- // Origins are still not enforced at the transport.
122
- expect(opts.allowedOrigins).toEqual([]);
123
- });
124
- });
125
-
126
- describe('SEC-CONFIG M10: Dockerfile runs as non-root', () => {
127
- it('Dockerfile declares a non-root USER', () => {
128
- const dockerfile = read('Dockerfile');
129
- const userLines = dockerfile.split('\n').filter((l) => /^\s*USER\s+/i.test(l));
130
- expect(userLines.length).toBeGreaterThan(0);
131
- // The last USER directive in effect must not be root.
132
- const last = userLines[userLines.length - 1].trim().replace(/^USER\s+/i, '');
133
- expect(last).not.toMatch(/^root\b/);
134
- expect(last).not.toMatch(/^0\b/);
135
- });
136
- });
137
-
138
- describe('SEC-CONFIG L4: baseline security headers', () => {
139
- it('applySecurityHeaders sets nosniff, frame-deny, and a referrer policy', () => {
140
- const set: Record<string, string> = {};
141
- const res = {
142
- setHeader(name: string, value: string) {
143
- set[name] = value;
144
- },
145
- } as unknown as import('node:http').ServerResponse;
146
-
147
- applySecurityHeaders(res);
148
-
149
- expect(set['X-Content-Type-Options']).toBe('nosniff');
150
- expect(set['X-Frame-Options']).toBe('DENY');
151
- expect(set['Referrer-Policy']).toBeDefined();
152
- });
153
- });
154
-
155
- describe('SEC-CONFIG L7: .env.example uses correct var + secure defaults', () => {
156
- const env = read('.env.example');
157
-
158
- it('uses MCP_AUTH_SECRET, never MCP_AUTH_TOKEN', () => {
159
- expect(env).toMatch(/MCP_AUTH_SECRET/);
160
- expect(env).not.toMatch(/MCP_AUTH_TOKEN/);
161
- });
162
-
163
- it('defaults HA_STRICT_SSL to true', () => {
164
- expect(env).toMatch(/^HA_STRICT_SSL=true\s*$/m);
165
- });
166
-
167
- it('defaults MCP_AUTH_METHOD to bearer', () => {
168
- expect(env).toMatch(/^MCP_AUTH_METHOD=bearer\s*$/m);
169
- });
170
- });
@@ -1,172 +0,0 @@
1
- /**
2
- * SEC-CRYPTO — Cryptographic / transport-security findings.
3
- *
4
- * OWASP API8:2023 Security Misconfiguration / A02:2021 Cryptographic Failures.
5
- *
6
- * H2 Per-client TLS scoping. The Omada and HA clients must NOT toggle the
7
- * process-global `NODE_TLS_REJECT_UNAUTHORIZED` env var to talk to a
8
- * self-signed controller — that disables certificate validation for EVERY
9
- * concurrent outbound request in the process (a TOCTOU race that silently
10
- * drops TLS verification for unrelated HA/AI traffic). The insecure mode
11
- * must be scoped to the single client via a per-instance undici dispatcher.
12
- *
13
- * L5 SSE client IDs must be generated with a CSPRNG (randomUUID), not
14
- * Math.random(), so they are not predictable/guessable.
15
- */
16
-
17
- import { describe, it, expect, vi, afterEach } from 'vitest';
18
- import { fetch as undiciFetch } from 'undici';
19
- import { RequestHandler } from '../../src/omadaClient/request.js';
20
- import { AuthManager } from '../../src/omadaClient/auth.js';
21
- import { createTlsDispatcher } from '../../src/utils/tlsDispatcher.js';
22
- import { generateClientId } from '../../src/server/eventSubscription.js';
23
-
24
- // Partial-mock undici: real Agent (createTlsDispatcher needs it), mocked fetch —
25
- // dispatcher-carrying requests must route here (THE PAIRING RULE, see below).
26
- vi.mock('undici', async (importOriginal) => {
27
- const actual = await importOriginal<typeof import('undici')>();
28
- return { ...actual, fetch: vi.fn() };
29
- });
30
-
31
- const ENV_KEY = 'NODE_TLS_REJECT_UNAUTHORIZED';
32
-
33
- function jsonResponse(body: unknown): Response {
34
- return new Response(JSON.stringify(body), {
35
- status: 200,
36
- headers: { 'content-type': 'application/json' },
37
- });
38
- }
39
-
40
- describe('SEC-CRYPTO H2: per-client TLS scoping (no global env mutation)', () => {
41
- const realFetch = global.fetch;
42
-
43
- afterEach(() => {
44
- global.fetch = realFetch;
45
- vi.restoreAllMocks();
46
- vi.mocked(undiciFetch).mockReset();
47
- delete process.env[ENV_KEY];
48
- });
49
-
50
- it('Omada request with strictSsl=false never mutates global NODE_TLS_REJECT_UNAUTHORIZED', async () => {
51
- let envDuringFetch: string | undefined = 'SENTINEL';
52
- let dispatcherDuringFetch: unknown;
53
-
54
- // Dispatcher requests route through UNDICI's fetch (pairing rule) — mock it.
55
- vi.mocked(undiciFetch).mockImplementation((async (_url: unknown, init: unknown) => {
56
- envDuringFetch = process.env[ENV_KEY];
57
- dispatcherDuringFetch = (init as { dispatcher?: unknown }).dispatcher;
58
- return jsonResponse({ errorCode: 0, result: {} });
59
- }) as unknown as typeof undiciFetch);
60
- const globalFetchSpy = vi.fn();
61
- global.fetch = globalFetchSpy as unknown as typeof fetch;
62
-
63
- const auth = {
64
- getAccessToken: async () => 'access-token',
65
- clearToken: () => {},
66
- } as unknown as AuthManager;
67
-
68
- const handler = new RequestHandler(
69
- { baseUrl: 'https://omada.example:8043', strictSsl: false },
70
- auth
71
- );
72
-
73
- await handler.get('/openapi/v1/anything');
74
-
75
- // The env var must NOT be set at any point — it stays undefined throughout.
76
- expect(envDuringFetch).toBeUndefined();
77
- expect(process.env[ENV_KEY]).toBeUndefined();
78
- // Insecure TLS must be carried by a per-call dispatcher instead.
79
- expect(dispatcherDuringFetch).toBeDefined();
80
- // THE PAIRING RULE (v1.5.5 incident regression): a dispatcher built from
81
- // the npm undici package must go to undici's OWN fetch — never Node's
82
- // built-in fetch, whose bundled undici drifts across majors (Node 26:
83
- // "invalid onError method" -> "fetch failed" crash-loop).
84
- expect(globalFetchSpy).not.toHaveBeenCalled();
85
- });
86
-
87
- it('Omada request with strictSsl=true sends no insecure dispatcher (built-in fetch)', async () => {
88
- let dispatcherDuringFetch: unknown = 'SENTINEL';
89
-
90
- global.fetch = vi.fn(async (_url: unknown, init: unknown) => {
91
- dispatcherDuringFetch = (init as { dispatcher?: unknown }).dispatcher;
92
- return jsonResponse({ errorCode: 0, result: {} });
93
- }) as unknown as typeof fetch;
94
-
95
- const auth = {
96
- getAccessToken: async () => 'access-token',
97
- clearToken: () => {},
98
- } as unknown as AuthManager;
99
-
100
- const handler = new RequestHandler(
101
- { baseUrl: 'https://omada.example:8043', strictSsl: true },
102
- auth
103
- );
104
-
105
- await handler.get('/openapi/v1/anything');
106
-
107
- expect(dispatcherDuringFetch).toBeUndefined();
108
- expect(process.env[ENV_KEY]).toBeUndefined();
109
- // No dispatcher -> Node's built-in fetch, undici's stays untouched.
110
- expect(vi.mocked(undiciFetch)).not.toHaveBeenCalled();
111
- });
112
-
113
- it('Omada auth with strictSsl=false never mutates global NODE_TLS_REJECT_UNAUTHORIZED', async () => {
114
- let envDuringFetch: string | undefined = 'SENTINEL';
115
- let dispatcherDuringFetch: unknown;
116
-
117
- vi.mocked(undiciFetch).mockImplementation((async (_url: unknown, init: unknown) => {
118
- envDuringFetch = process.env[ENV_KEY];
119
- dispatcherDuringFetch = (init as { dispatcher?: unknown }).dispatcher;
120
- return jsonResponse({
121
- errorCode: 0,
122
- result: { accessToken: 'a', refreshToken: 'r', expiresIn: 3600 },
123
- });
124
- }) as unknown as typeof undiciFetch);
125
- const globalFetchSpy = vi.fn();
126
- global.fetch = globalFetchSpy as unknown as typeof fetch;
127
-
128
- const auth = new AuthManager({
129
- baseUrl: 'https://omada.example:8043',
130
- clientId: 'id',
131
- clientSecret: 'secret',
132
- omadacId: 'omadac',
133
- strictSsl: false,
134
- });
135
-
136
- const token = await auth.getAccessToken();
137
-
138
- expect(token).toBe('a');
139
- expect(envDuringFetch).toBeUndefined();
140
- expect(process.env[ENV_KEY]).toBeUndefined();
141
- expect(dispatcherDuringFetch).toBeDefined();
142
- expect(globalFetchSpy).not.toHaveBeenCalled();
143
- });
144
-
145
- it('plain-http base with strictSsl=false carries NO dispatcher (v1.5.5 NAS config)', async () => {
146
- // The exact production config that crash-looped v1.5.5: http HA +
147
- // HA_STRICT_SSL=false. TLS relaxation is meaningless without TLS, so no
148
- // dispatcher is built at all -> built-in fetch -> immune to undici drift.
149
- expect(createTlsDispatcher(false, 'http://192.168.0.19:8123')).toBeUndefined();
150
- expect(createTlsDispatcher(false, 'HTTP://UPPER.example')).toBeUndefined();
151
- // https targets still get the relaxing dispatcher.
152
- expect(createTlsDispatcher(false, 'https://omada.example')).toBeDefined();
153
- // And with no baseUrl hint, behavior is unchanged (dispatcher created).
154
- expect(createTlsDispatcher(false)).toBeDefined();
155
- expect(createTlsDispatcher(true, 'https://omada.example')).toBeUndefined();
156
- });
157
- });
158
-
159
- describe('SEC-CRYPTO L5: SSE client IDs use a CSPRNG', () => {
160
- it('generateClientId returns an unpredictable UUID, not a Math.random/timestamp string', () => {
161
- const id = generateClientId();
162
- // RFC 4122 v4 UUID shape.
163
- expect(id).toMatch(
164
- /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
165
- );
166
- });
167
-
168
- it('generateClientId returns distinct values across calls', () => {
169
- const ids = new Set(Array.from({ length: 100 }, () => generateClientId()));
170
- expect(ids.size).toBe(100);
171
- });
172
- });
@@ -1,152 +0,0 @@
1
- /**
2
- * SEC-DEPS — Vulnerable & Outdated Components (OWASP A06:2021)
3
- *
4
- * Finding M9: production dependency tree shipped known-vulnerable transitive
5
- * packages (hono / qs / path-to-regexp via the MCP SDK, plus a direct `ws`).
6
- *
7
- * These tests are the durable CI gate for that finding:
8
- * 1. `npm audit --omit=dev` must report ZERO high/critical advisories in the
9
- * PRODUCTION dependency tree (dev-only tooling is out of scope). A small,
10
- * explicitly-documented allowlist exists for unavoidable transitive advisories
11
- * that have no upstream fix yet — each entry must be justified in a comment.
12
- * 2. After bumping the SDK/ws, re-assert the per-request transport invariant so a
13
- * dependency upgrade can never silently change how transports are constructed.
14
- */
15
-
16
- import { describe, it, expect } from 'vitest';
17
- import { execFileSync } from 'node:child_process';
18
- import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
19
-
20
- /**
21
- * Advisory IDs (GHSA / npm) we knowingly accept because there is no fixed
22
- * upstream release reachable through our dependency tree. MUST stay empty unless
23
- * a transitive advisory is genuinely unfixable — add the ID with a justification.
24
- */
25
- const HIGH_CRITICAL_ALLOWLIST = new Set<string | number>([
26
- // (intentionally empty — all known high/critical advisories are fixed)
27
- ]);
28
-
29
- interface AuditAdvisory {
30
- severity: string;
31
- source?: number;
32
- url?: string;
33
- title?: string;
34
- name?: string;
35
- }
36
-
37
- interface AuditReport {
38
- vulnerabilities?: Record<
39
- string,
40
- {
41
- severity: string;
42
- via: Array<string | AuditAdvisory>;
43
- }
44
- >;
45
- metadata?: {
46
- vulnerabilities?: Record<string, number>;
47
- };
48
- }
49
-
50
- /**
51
- * Run `npm audit --omit=dev --json` and return the parsed report. npm audit exits
52
- * NON-zero when advisories are present, so we capture stdout from the thrown error
53
- * too. Returns null when the registry is unreachable (offline dev) so the gate
54
- * skips rather than producing a false failure.
55
- */
56
- function runProdAudit(): AuditReport | null {
57
- let stdout: string;
58
- try {
59
- stdout = execFileSync('npm', ['audit', '--omit=dev', '--json'], {
60
- encoding: 'utf8',
61
- shell: process.platform === 'win32', // npm is npm.cmd on Windows
62
- stdio: ['ignore', 'pipe', 'ignore'],
63
- maxBuffer: 32 * 1024 * 1024,
64
- });
65
- } catch (err) {
66
- const e = err as { stdout?: string; message?: string };
67
- if (typeof e.stdout === 'string' && e.stdout.trim().startsWith('{')) {
68
- stdout = e.stdout;
69
- } else {
70
- // Could not run audit (offline / registry down) — signal skip.
71
- return null;
72
- }
73
- }
74
-
75
- try {
76
- return JSON.parse(stdout) as AuditReport;
77
- } catch {
78
- return null;
79
- }
80
- }
81
-
82
- describe('SEC-DEPS — Vulnerable & Outdated Components (M9)', () => {
83
- describe('production audit gate', () => {
84
- it('reports zero high/critical advisories in the production tree', () => {
85
- const report = runProdAudit();
86
- if (report === null) {
87
- // Registry unreachable — do not fail the suite offline.
88
- console.warn('SEC-DEPS: npm audit unavailable (offline?), skipping gate assertion');
89
- return;
90
- }
91
-
92
- const offenders: string[] = [];
93
- for (const [pkg, vuln] of Object.entries(report.vulnerabilities ?? {})) {
94
- const sev = vuln.severity?.toLowerCase();
95
- if (sev !== 'high' && sev !== 'critical') {
96
- continue;
97
- }
98
- // Allow only if EVERY advisory behind this package is allowlisted.
99
- const advisoryIds = vuln.via
100
- .filter((v): v is AuditAdvisory => typeof v === 'object')
101
- .map((v) => v.source)
102
- .filter((s): s is number => typeof s === 'number');
103
- const allAllowlisted =
104
- advisoryIds.length > 0 && advisoryIds.every((id) => HIGH_CRITICAL_ALLOWLIST.has(id));
105
- if (!allAllowlisted) {
106
- offenders.push(`${pkg} (${sev}; advisories: ${advisoryIds.join(', ') || 'n/a'})`);
107
- }
108
- }
109
-
110
- expect(offenders, `Unfixed high/critical prod advisories:\n${offenders.join('\n')}`).toEqual(
111
- []
112
- );
113
- });
114
-
115
- it('reports zero high/critical via the audit metadata summary', () => {
116
- const report = runProdAudit();
117
- if (report === null) {
118
- console.warn('SEC-DEPS: npm audit unavailable (offline?), skipping metadata assertion');
119
- return;
120
- }
121
- const counts = report.metadata?.vulnerabilities ?? {};
122
- const high = counts.high ?? 0;
123
- const critical = counts.critical ?? 0;
124
- // With an empty allowlist the metadata summary must also be clean.
125
- if (HIGH_CRITICAL_ALLOWLIST.size === 0) {
126
- expect(high + critical).toBe(0);
127
- }
128
- });
129
- });
130
-
131
- describe('per-request transport invariant (post-upgrade)', () => {
132
- it('SDK still exposes a constructable StreamableHTTPServerTransport', () => {
133
- expect(typeof NodeStreamableHTTPServerTransport).toBe('function');
134
- });
135
-
136
- it('constructs an independent transport per call (stateless mode)', () => {
137
- const make = (): NodeStreamableHTTPServerTransport =>
138
- new NodeStreamableHTTPServerTransport({
139
- sessionIdGenerator: undefined, // stateless: one transport per request
140
- enableDnsRebindingProtection: true,
141
- allowedHosts: ['localhost:3000'],
142
- allowedOrigins: ['http://localhost:3000'],
143
- });
144
-
145
- const a = make();
146
- const b = make();
147
- expect(a).toBeInstanceOf(NodeStreamableHTTPServerTransport);
148
- expect(b).toBeInstanceOf(NodeStreamableHTTPServerTransport);
149
- expect(a).not.toBe(b); // distinct instances — no shared session state
150
- });
151
- });
152
- });
@@ -1,117 +0,0 @@
1
- /**
2
- * SEC-INJ — Injection (path / parameter)
3
- * OWASP A03:2021 (Injection); overlaps SEC-SSRF (A10:2021).
4
- *
5
- * Finding covered:
6
- * H4 — Home Assistant API paths interpolate user input without encoding, so an
7
- * entity_id / domain / service containing "/", "..", "?" or "#" could escape
8
- * the intended /api/<resource> path or inject query parameters.
9
- *
10
- * Secure behavior asserted:
11
- * - Call sites percent-encode each user-controlled path segment.
12
- * - The request layer refuses any path that resolves outside /api/ (traversal guard).
13
- */
14
-
15
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
16
- import { RequestHandler } from '../../src/haClient/request.js';
17
- import { StateOperations } from '../../src/haClient/states.js';
18
- import { ServiceOperations } from '../../src/haClient/services.js';
19
-
20
- function makeHandler() {
21
- return new RequestHandler({
22
- baseUrl: 'http://ha.test:8123',
23
- token: 'tok',
24
- timeout: 30000,
25
- strictSsl: true,
26
- });
27
- }
28
-
29
- /** Capture the URL string passed to fetch and return an empty JSON response. */
30
- function stubFetch() {
31
- const fetchMock = vi.fn(async () => ({
32
- ok: true,
33
- status: 200,
34
- statusText: 'OK',
35
- headers: { get: () => 'application/json' },
36
- json: async () => ({}),
37
- text: async () => '',
38
- }));
39
- vi.stubGlobal('fetch', fetchMock);
40
- return fetchMock;
41
- }
42
-
43
- describe('SEC-INJ — Injection (H4: HA API path)', () => {
44
- beforeEach(() => {
45
- stubFetch();
46
- });
47
- afterEach(() => {
48
- vi.unstubAllGlobals();
49
- vi.restoreAllMocks();
50
- });
51
-
52
- describe('request-layer traversal guard', () => {
53
- it('rejects a path that escapes /api via ".." before making any request', async () => {
54
- const handler = makeHandler();
55
- await expect(handler.get('/states/../../secret')).rejects.toThrow();
56
- expect(fetch).not.toHaveBeenCalled();
57
- });
58
-
59
- it('allows a normal /api path', async () => {
60
- const handler = makeHandler();
61
- await handler.get('/states');
62
- expect(fetch).toHaveBeenCalledOnce();
63
- const calledUrl = (fetch as unknown as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
64
- expect(new URL(calledUrl).pathname).toBe('/api/states');
65
- });
66
- });
67
-
68
- describe('call-site encoding — StateOperations.getState', () => {
69
- it('encodes a malicious entity_id so it cannot escape /api/states', async () => {
70
- const states = new StateOperations(makeHandler());
71
- await states.getState('x/../../admin');
72
-
73
- const calledUrl = (fetch as unknown as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
74
- const { pathname } = new URL(calledUrl);
75
- // The whole id stays a single encoded segment under /api/states/.
76
- expect(pathname.startsWith('/api/states/')).toBe(true);
77
- expect(pathname).not.toContain('/admin');
78
- expect(calledUrl).toContain('%2F'); // the slashes were encoded
79
- });
80
-
81
- it('encodes "?" so it cannot inject query parameters', async () => {
82
- const states = new StateOperations(makeHandler());
83
- await states.getState('light.x?evil=1');
84
-
85
- const calledUrl = (fetch as unknown as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
86
- expect(new URL(calledUrl).searchParams.get('evil')).toBeNull();
87
- });
88
-
89
- it('passes a normal entity_id through unchanged', async () => {
90
- const states = new StateOperations(makeHandler());
91
- await states.getState('light.kitchen');
92
-
93
- const calledUrl = (fetch as unknown as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
94
- expect(new URL(calledUrl).pathname).toBe('/api/states/light.kitchen');
95
- });
96
- });
97
-
98
- describe('call-site encoding — ServiceOperations.callService', () => {
99
- it('encodes domain and service segments', async () => {
100
- const services = new ServiceOperations(makeHandler());
101
- await services.callService({ domain: 'light', service: 'turn_on/../../admin' });
102
-
103
- const calledUrl = (fetch as unknown as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
104
- const { pathname } = new URL(calledUrl);
105
- expect(pathname.startsWith('/api/services/light/')).toBe(true);
106
- expect(pathname).not.toContain('/admin');
107
- });
108
-
109
- it('passes a normal service call through unchanged', async () => {
110
- const services = new ServiceOperations(makeHandler());
111
- await services.callService({ domain: 'light', service: 'turn_on' });
112
-
113
- const calledUrl = (fetch as unknown as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
114
- expect(new URL(calledUrl).pathname).toBe('/api/services/light/turn_on');
115
- });
116
- });
117
- });