@sensolus/create-snt-agent-app 0.3.13 → 0.3.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sensolus/create-snt-agent-app",
3
- "version": "0.3.13",
3
+ "version": "0.3.14",
4
4
  "description": "Scaffold a new Sensolus agent app: React frontend wired to @sensolus/snt-agent-kit + Flask API proxy backend.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,6 +25,12 @@ RUN pip install --no-cache-dir -r requirements.txt
25
25
  COPY backend/ backend/
26
26
  COPY --from=builder /app/dist dist/
27
27
 
28
+ # Run as non-root user
29
+ RUN groupadd --system --gid 1001 app \
30
+ && useradd --system --uid 1001 --gid app --no-create-home app \
31
+ && chown -R app:app /app
32
+ USER app
33
+
28
34
  EXPOSE 5000
29
35
 
30
36
  # Run with socketio.run() for proper WebSocket support (not gunicorn)
@@ -11,6 +11,11 @@ from dotenv import load_dotenv
11
11
 
12
12
  from db_config import get_database_uri
13
13
  from extensions import db
14
+ from sensolus_client_api import (
15
+ SENSOLUS_BASE_URL,
16
+ SENSOLUS_COOKIE_NAME,
17
+ make_sensolus_request,
18
+ )
14
19
 
15
20
  # Get the project root (parent of backend folder)
16
21
  PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -46,10 +51,6 @@ import models # noqa: F401
46
51
  migrations_dir = os.path.join(os.path.dirname(__file__), 'migrations')
47
52
  Migrate(app, db, directory=migrations_dir)
48
53
 
49
- SENSOLUS_DOMAIN = os.environ.get("SENSOLUS_DOMAIN", "cloud.sensolus.com")
50
- SENSOLUS_BASE_URL = f"https://{SENSOLUS_DOMAIN}/rest/api/v2"
51
- SENSOLUS_COOKIE_NAME = os.environ.get("SENSOLUS_COOKIE_NAME", "prod-sensolus-token")
52
-
53
54
  MANAGER_AUTH_KEY = os.environ.get("MANAGER_AUTH_KEY")
54
55
 
55
56
  # Manager-provided headers — typos in string literals silently 401, so route everything through these.
@@ -163,127 +164,6 @@ def heartbeat():
163
164
  logger.info("Scheduled heartbeat: the background scheduler is alive and well.")
164
165
 
165
166
 
166
- def get_auth_headers_and_params():
167
- """
168
- Get authentication headers and params for Sensolus API calls.
169
-
170
- Priority:
171
- 1. Session cookie (SENSOLUS_COOKIE_NAME) -> passed as Bearer token
172
- 2. API key from query parameter -> passed as apiKey query param
173
- 3. Stored API key from session -> passed as apiKey query param
174
-
175
- Returns:
176
- tuple: (headers dict, params dict, error message or None)
177
- """
178
- headers = {}
179
- params = {}
180
-
181
- # Check for session cookie first - pass as Bearer token
182
- token = request.cookies.get(SENSOLUS_COOKIE_NAME)
183
- if token:
184
- headers['Authorization'] = f'Bearer {token}'
185
- logger.info("Using session cookie as Bearer token for authentication")
186
- return headers, params, None
187
-
188
- # Check for API key in query parameter
189
- api_key = request.args.get('apiKey')
190
- if api_key:
191
- # Store API key in session for future requests
192
- session['api_key'] = api_key
193
- params['apiKey'] = api_key
194
- logger.info("Using API key from query parameter")
195
- return headers, params, None
196
-
197
- # Check for useCookie flag without actual cookie
198
- if request.args.get('useCookie') == 'true':
199
- logger.warning("useCookie=true but no session token found")
200
- return None, None, "No session token found in cookies"
201
-
202
- # Try to use stored API key from session
203
- stored_api_key = session.get('api_key')
204
- if stored_api_key:
205
- params['apiKey'] = stored_api_key
206
- logger.info("Using stored API key from session")
207
- return headers, params, None
208
-
209
- logger.warning("No authentication method available")
210
- return None, None, "API key is required"
211
-
212
-
213
- def make_sensolus_request(endpoint, extra_params=None, method='GET', json_body=None):
214
- """
215
- Make an authenticated request to the Sensolus API.
216
-
217
- Args:
218
- endpoint: API endpoint (e.g., '/organisations')
219
- extra_params: Additional query parameters
220
- method: HTTP method ('GET' or 'POST')
221
- json_body: JSON body for POST requests
222
-
223
- Returns:
224
- tuple: (response_data, status_code)
225
- """
226
- headers, params, error = get_auth_headers_and_params()
227
-
228
- if error:
229
- return {"error": error}, 401 if "session token" in error else 400
230
-
231
- if extra_params:
232
- params.update(extra_params)
233
-
234
- url = f"{SENSOLUS_BASE_URL}{endpoint}"
235
-
236
- # Log the request (mask sensitive data)
237
- log_params = {k: '***' if k == 'apiKey' else v for k, v in params.items()}
238
- log_headers = {k: '***' if k == 'Authorization' else v for k, v in headers.items()}
239
- logger.info(f"API Request: {method} {url}")
240
- logger.info(f" Headers: {log_headers}")
241
- logger.info(f" Params: {log_params}")
242
- if json_body:
243
- logger.info(f" Body: {json_body}")
244
-
245
- try:
246
- if method == 'POST':
247
- headers['Content-Type'] = 'application/json'
248
- response = requests.post(
249
- url,
250
- params=params,
251
- headers=headers,
252
- json=json_body,
253
- timeout=30
254
- )
255
- else:
256
- response = requests.get(
257
- url,
258
- params=params,
259
- headers=headers,
260
- timeout=30
261
- )
262
-
263
- logger.info(f"API Response: {response.status_code} from {endpoint}")
264
-
265
- if response.status_code >= 400:
266
- logger.error(f"API Error: {response.status_code} - {response.text[:500]}")
267
-
268
- response.raise_for_status()
269
- return response.json(), 200
270
-
271
- except requests.exceptions.HTTPError as e:
272
- error_msg = f"API error: {e.response.status_code}"
273
- if e.response.text:
274
- try:
275
- error_detail = e.response.json()
276
- error_msg = f"{error_msg} - {error_detail.get('message', e.response.text[:200])}"
277
- except Exception:
278
- error_msg = f"{error_msg} - {e.response.text[:200]}"
279
- logger.error(f"HTTP Error on {endpoint}: {error_msg}")
280
- return {"error": error_msg}, e.response.status_code
281
-
282
- except requests.exceptions.RequestException as e:
283
- logger.error(f"Request Exception on {endpoint}: {str(e)}")
284
- return {"error": str(e)}, 500
285
-
286
-
287
167
  @app.route('/')
288
168
  def index():
289
169
  return send_from_directory(app.static_folder, 'index.html')
@@ -658,6 +538,7 @@ if __name__ == '__main__':
658
538
  logger.warning(f"DB init skipped: {e}")
659
539
 
660
540
  scheduler.start()
661
- logger.info("Starting server on port 5000 (scheduler active)")
662
- socketio.run(app, debug=True, port=5000, host='0.0.0.0',
663
- allow_unsafe_werkzeug=True, use_reloader=False)
541
+ debug_mode = os.getenv('FLASK_DEBUG', '0').lower() in ('1', 'true', 'yes')
542
+ logger.info(f"Starting server on port 5000 (scheduler active, debug={debug_mode})")
543
+ socketio.run(app, debug=debug_mode, port=5000, host='0.0.0.0',
544
+ allow_unsafe_werkzeug=debug_mode, use_reloader=False)
@@ -1,8 +1,8 @@
1
- flask
2
- flask-apscheduler
3
- flask-socketio
4
- flask-sqlalchemy
5
- flask-migrate
6
- requests
7
- python-dotenv
8
- psycopg2-binary
1
+ flask==3.1.3
2
+ flask-apscheduler==1.13.1
3
+ flask-socketio==5.6.1
4
+ flask-sqlalchemy==3.1.1
5
+ flask-migrate==4.1.0
6
+ requests==2.34.2
7
+ python-dotenv==1.2.2
8
+ psycopg2-binary==2.9.12
@@ -0,0 +1,190 @@
1
+ """Sensolus public REST API client.
2
+
3
+ Wraps authenticated requests to the Sensolus API for the Flask backend.
4
+ Endpoint inputs are validated so they cannot escape the configured base URL
5
+ (no absolute URLs, protocol-relative URLs, path traversal, or query/fragment
6
+ injection). Cross-host redirects are also disabled.
7
+ """
8
+ import logging
9
+ import os
10
+ import re
11
+
12
+ import requests
13
+ from flask import request, session
14
+ from urllib.parse import urlparse
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ SENSOLUS_DOMAIN = os.environ.get("SENSOLUS_DOMAIN", "cloud.sensolus.com")
19
+ SENSOLUS_BASE_URL = f"https://{SENSOLUS_DOMAIN}/rest/api/v2"
20
+ SENSOLUS_COOKIE_NAME = os.environ.get("SENSOLUS_COOKIE_NAME", "prod-sensolus-token")
21
+
22
+ # Endpoints are conservative relative paths: alphanumerics plus '/', '-', '_', '.'.
23
+ # Query strings are built separately from the params dict — not part of endpoint.
24
+ _ALLOWED_ENDPOINT_RE = re.compile(r'^/[A-Za-z0-9/_\-.]*$')
25
+
26
+ _EXPECTED_HOST = urlparse(SENSOLUS_BASE_URL).hostname
27
+
28
+
29
+ def _sanitize_endpoint(endpoint):
30
+ """Validate the endpoint is a safe relative path under the Sensolus API base.
31
+
32
+ Rejects absolute URLs, protocol-relative URLs, path traversal, query/fragment
33
+ in the endpoint, and any characters outside a conservative allowlist. This
34
+ guarantees the final URL stays on the configured Sensolus host.
35
+ """
36
+ if not isinstance(endpoint, str) or not endpoint:
37
+ raise ValueError("endpoint must be a non-empty string")
38
+ if not endpoint.startswith('/') or endpoint.startswith('//'):
39
+ raise ValueError(f"endpoint must be a single-slash relative path: {endpoint!r}")
40
+ if '..' in endpoint:
41
+ raise ValueError(f"endpoint must not contain '..': {endpoint!r}")
42
+ if '?' in endpoint or '#' in endpoint or '\\' in endpoint:
43
+ raise ValueError("endpoint must not contain '?', '#' or '\\'; use params for query strings")
44
+ if not _ALLOWED_ENDPOINT_RE.match(endpoint):
45
+ raise ValueError(f"endpoint contains invalid characters: {endpoint!r}")
46
+ return endpoint
47
+
48
+
49
+ def get_auth_headers_and_params():
50
+ """
51
+ Get authentication headers and params for Sensolus API calls.
52
+
53
+ Priority:
54
+ 1. Session cookie (SENSOLUS_COOKIE_NAME) -> passed as Bearer token
55
+ 2. API key from query parameter -> passed as apiKey query param
56
+ 3. Stored API key from session -> passed as apiKey query param
57
+
58
+ Returns:
59
+ tuple: (headers dict, params dict, error message or None)
60
+ """
61
+ headers = {}
62
+ params = {}
63
+
64
+ # Check for session cookie first - pass as Bearer token
65
+ token = request.cookies.get(SENSOLUS_COOKIE_NAME)
66
+ if token:
67
+ headers['Authorization'] = f'Bearer {token}'
68
+ logger.info("Using session cookie as Bearer token for authentication")
69
+ return headers, params, None
70
+
71
+ # Check for API key in query parameter
72
+ api_key = request.args.get('apiKey')
73
+ if api_key:
74
+ # Store API key in session for future requests
75
+ session['api_key'] = api_key
76
+ params['apiKey'] = api_key
77
+ logger.info("Using API key from query parameter")
78
+ return headers, params, None
79
+
80
+ # Check for useCookie flag without actual cookie
81
+ if request.args.get('useCookie') == 'true':
82
+ logger.warning("useCookie=true but no session token found")
83
+ return None, None, "No session token found in cookies"
84
+
85
+ # Try to use stored API key from session
86
+ stored_api_key = session.get('api_key')
87
+ if stored_api_key:
88
+ params['apiKey'] = stored_api_key
89
+ logger.info("Using stored API key from session")
90
+ return headers, params, None
91
+
92
+ logger.warning("No authentication method available")
93
+ return None, None, "API key is required"
94
+
95
+
96
+ def make_sensolus_request(endpoint, extra_params=None, method='GET', json_body=None):
97
+ """
98
+ Make an authenticated request to the Sensolus API.
99
+
100
+ Args:
101
+ endpoint: API endpoint relative to SENSOLUS_BASE_URL (e.g., '/organisations').
102
+ Validated against a strict allowlist — absolute URLs, protocol-relative
103
+ URLs, path traversal, and query/fragment characters are rejected.
104
+ extra_params: Additional query parameters
105
+ method: HTTP method ('GET' or 'POST')
106
+ json_body: JSON body for POST requests
107
+
108
+ Returns:
109
+ tuple: (response_data, status_code)
110
+ """
111
+ try:
112
+ endpoint = _sanitize_endpoint(endpoint)
113
+ except ValueError as e:
114
+ logger.error(f"Rejected unsafe endpoint: {e}")
115
+ return {"error": "Invalid API endpoint"}, 400
116
+
117
+ headers, params, error = get_auth_headers_and_params()
118
+
119
+ if error:
120
+ return {"error": error}, 401 if "session token" in error else 400
121
+
122
+ if extra_params:
123
+ params.update(extra_params)
124
+
125
+ url = f"{SENSOLUS_BASE_URL}{endpoint}"
126
+
127
+ # Defence in depth: even though endpoint is sanitized, re-parse and verify
128
+ # the final URL still points at the expected Sensolus host before sending.
129
+ parsed = urlparse(url)
130
+ if parsed.scheme != 'https' or parsed.hostname != _EXPECTED_HOST:
131
+ logger.error(f"Refusing to send request to unexpected host: {parsed.hostname}")
132
+ return {"error": "Invalid API endpoint"}, 400
133
+
134
+ # Log the request (mask sensitive data)
135
+ log_params = {k: '***' if k == 'apiKey' else v for k, v in params.items()}
136
+ log_headers = {k: '***' if k == 'Authorization' else v for k, v in headers.items()}
137
+ logger.info(f"API Request: {method} {url}")
138
+ logger.info(f" Headers: {log_headers}")
139
+ logger.info(f" Params: {log_params}")
140
+ if json_body:
141
+ logger.info(f" Body: {json_body}")
142
+
143
+ try:
144
+ if method == 'POST':
145
+ headers['Content-Type'] = 'application/json'
146
+ response = requests.post(
147
+ url,
148
+ params=params,
149
+ headers=headers,
150
+ json=json_body,
151
+ timeout=30,
152
+ allow_redirects=False,
153
+ )
154
+ else:
155
+ response = requests.get(
156
+ url,
157
+ params=params,
158
+ headers=headers,
159
+ timeout=30,
160
+ allow_redirects=False,
161
+ )
162
+
163
+ logger.info(f"API Response: {response.status_code} from {endpoint}")
164
+
165
+ # Block server-initiated redirects — we will not chase the Sensolus API
166
+ # off to another host or path under any circumstances.
167
+ if 300 <= response.status_code < 400:
168
+ logger.error(f"Refusing to follow redirect from {endpoint} to {response.headers.get('Location')!r}")
169
+ return {"error": "Upstream redirect refused"}, 502
170
+
171
+ if response.status_code >= 400:
172
+ logger.error(f"API Error: {response.status_code} - {response.text[:500]}")
173
+
174
+ response.raise_for_status()
175
+ return response.json(), 200
176
+
177
+ except requests.exceptions.HTTPError as e:
178
+ error_msg = f"API error: {e.response.status_code}"
179
+ if e.response.text:
180
+ try:
181
+ error_detail = e.response.json()
182
+ error_msg = f"{error_msg} - {error_detail.get('message', e.response.text[:200])}"
183
+ except Exception:
184
+ error_msg = f"{error_msg} - {e.response.text[:200]}"
185
+ logger.error(f"HTTP Error on {endpoint}: {error_msg}")
186
+ return {"error": error_msg}, e.response.status_code
187
+
188
+ except requests.exceptions.RequestException as e:
189
+ logger.error(f"Request Exception on {endpoint}: {str(e)}")
190
+ return {"error": str(e)}, 500