securescout-iast 0.1.0__tar.gz

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.
@@ -0,0 +1,69 @@
1
+ Metadata-Version: 2.4
2
+ Name: securescout-iast
3
+ Version: 0.1.0
4
+ Summary: Interactive Application Security Testing (IAST) runtime agent for Python
5
+ License: MIT
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: License :: OSI Approved :: MIT License
8
+ Classifier: Operating System :: OS Independent
9
+ Classifier: Topic :: Security
10
+ Classifier: Framework :: FastAPI
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+
14
+ # securescout-iast
15
+
16
+ Interactive Application Security Testing (IAST) runtime agent for Python web applications. Detects SQL injection vulnerabilities in real time by tracing untrusted request data as it flows into database queries during normal application traffic.
17
+
18
+ ## How it works
19
+
20
+ `securescout-iast` tags incoming request data (query parameters, headers, cookies, and JSON/form body) at the ASGI layer, then watches for that data appearing in raw SQL execute calls. If untrusted input reaches a database query without being safely parameterized, a finding is reported to your SecureScout dashboard — confirmed by actual runtime execution, not static guesswork.
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ pip install securescout-iast
26
+ ```
27
+
28
+ ## Quick start (FastAPI / Starlette)
29
+
30
+ ```python
31
+ from fastapi import FastAPI
32
+ from securescout_iast import SecureScoutIastMiddleware, init
33
+
34
+ app = FastAPI()
35
+
36
+ init(
37
+ api_key="ssk_live_your_api_key",
38
+ project_id="your-project-id",
39
+ )
40
+ app.add_middleware(SecureScoutIastMiddleware)
41
+ ```
42
+
43
+ Get your API key and project ID from **Settings → API Keys** and your project's **Runtime (IAST)** tab in the SecureScout dashboard.
44
+
45
+ ## Supported database drivers
46
+
47
+ - `psycopg2` (sync PostgreSQL)
48
+ - `asyncpg` (async PostgreSQL, including async SQLAlchemy)
49
+
50
+ Drivers are detected automatically. If a driver isn't installed in your environment, that patch is silently skipped — no errors, no extra dependencies pulled in.
51
+
52
+ ## Detected vulnerability classes
53
+
54
+ - SQL Injection (CWE-89) — v1
55
+
56
+ ## Safety guarantees
57
+
58
+ - `init()` never raises. Misconfiguration or network issues degrade to a silent no-op, never a crash.
59
+ - Your request and database driver behavior are never modified — the agent only observes.
60
+ - Request bodies over 1MB are not buffered for taint analysis (still passed through to your app unmodified).
61
+ - No third-party dependencies. Pure standard library.
62
+
63
+ ## Privacy
64
+
65
+ This agent inspects request data and SQL query text locally, within your application process, to detect taint matches. Only confirmed findings (rule type, query snippet, stack trace, endpoint) are sent to SecureScout — never raw request bodies or full traffic.
66
+
67
+ ## License
68
+
69
+ MIT
@@ -0,0 +1,56 @@
1
+ # securescout-iast
2
+
3
+ Interactive Application Security Testing (IAST) runtime agent for Python web applications. Detects SQL injection vulnerabilities in real time by tracing untrusted request data as it flows into database queries during normal application traffic.
4
+
5
+ ## How it works
6
+
7
+ `securescout-iast` tags incoming request data (query parameters, headers, cookies, and JSON/form body) at the ASGI layer, then watches for that data appearing in raw SQL execute calls. If untrusted input reaches a database query without being safely parameterized, a finding is reported to your SecureScout dashboard — confirmed by actual runtime execution, not static guesswork.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ pip install securescout-iast
13
+ ```
14
+
15
+ ## Quick start (FastAPI / Starlette)
16
+
17
+ ```python
18
+ from fastapi import FastAPI
19
+ from securescout_iast import SecureScoutIastMiddleware, init
20
+
21
+ app = FastAPI()
22
+
23
+ init(
24
+ api_key="ssk_live_your_api_key",
25
+ project_id="your-project-id",
26
+ )
27
+ app.add_middleware(SecureScoutIastMiddleware)
28
+ ```
29
+
30
+ Get your API key and project ID from **Settings → API Keys** and your project's **Runtime (IAST)** tab in the SecureScout dashboard.
31
+
32
+ ## Supported database drivers
33
+
34
+ - `psycopg2` (sync PostgreSQL)
35
+ - `asyncpg` (async PostgreSQL, including async SQLAlchemy)
36
+
37
+ Drivers are detected automatically. If a driver isn't installed in your environment, that patch is silently skipped — no errors, no extra dependencies pulled in.
38
+
39
+ ## Detected vulnerability classes
40
+
41
+ - SQL Injection (CWE-89) — v1
42
+
43
+ ## Safety guarantees
44
+
45
+ - `init()` never raises. Misconfiguration or network issues degrade to a silent no-op, never a crash.
46
+ - Your request and database driver behavior are never modified — the agent only observes.
47
+ - Request bodies over 1MB are not buffered for taint analysis (still passed through to your app unmodified).
48
+ - No third-party dependencies. Pure standard library.
49
+
50
+ ## Privacy
51
+
52
+ This agent inspects request data and SQL query text locally, within your application process, to detect taint matches. Only confirmed findings (rule type, query snippet, stack trace, endpoint) are sent to SecureScout — never raw request bodies or full traffic.
53
+
54
+ ## License
55
+
56
+ MIT
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "securescout-iast"
7
+ version = "0.1.0"
8
+ description = "Interactive Application Security Testing (IAST) runtime agent for Python"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = {text = "MIT"}
12
+ classifiers = [
13
+ "Programming Language :: Python :: 3",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Operating System :: OS Independent",
16
+ "Topic :: Security",
17
+ "Framework :: FastAPI",
18
+ ]
19
+ dependencies = []
20
+
21
+ [tool.setuptools.packages.find]
22
+ where = ["."]
23
+ include = ["securescout_iast*"]
@@ -0,0 +1,60 @@
1
+ import logging
2
+ import threading
3
+ from securescout_iast.config import DEFAULT_BACKEND_URL
4
+ from securescout_iast.reporter import init_reporter, send_heartbeat, queue_finding
5
+ from securescout_iast.patches.psycopg2_patch import install_psycopg2_patch
6
+ from securescout_iast.patches.asyncpg_patch import install_asyncpg_patch
7
+ from securescout_iast.middleware import SecureScoutIastMiddleware
8
+
9
+ # Expose the ASGI middleware class and the init entrypoint
10
+ __all__ = ["SecureScoutIastMiddleware", "init"]
11
+
12
+ logger = logging.getLogger("securescout_iast")
13
+ _initialized = False
14
+
15
+
16
+ def init(
17
+ api_key: str,
18
+ project_id: str,
19
+ backend_url: str = DEFAULT_BACKEND_URL,
20
+ framework: str = "fastapi"
21
+ ) -> None:
22
+ """
23
+ Initializes the SecureScout IAST runtime monitoring agent.
24
+ Safely monkey-patches database connection libraries and spawns background telemetry loops.
25
+ Guarantees no exception propagation to avoid interrupting customer application startup.
26
+ """
27
+ global _initialized
28
+ if _initialized:
29
+ logger.warning("SecureScout IAST agent is already initialized. Skipping setup.")
30
+ return
31
+
32
+ try:
33
+ # Check inputs and degrade to no-op on missing parameters without raising ValueErrors
34
+ if not api_key:
35
+ logger.error("SecureScout IAST initialization aborted: API Key must not be empty.")
36
+ return
37
+ if not project_id:
38
+ logger.error("SecureScout IAST initialization aborted: Project ID must not be empty.")
39
+ return
40
+
41
+ # 1. Initialize background telemetry batch reporter daemon
42
+ init_reporter(api_key=api_key, project_id=project_id, backend_url=backend_url)
43
+
44
+ # 2. Inject database driver interception monkey-patches
45
+ install_psycopg2_patch(queue_finding)
46
+ install_asyncpg_patch(queue_finding)
47
+
48
+ # 3. Transmit connection heartbeat on a background thread to prevent blocking boot probes
49
+ threading.Thread(
50
+ target=send_heartbeat,
51
+ args=(framework,),
52
+ daemon=True
53
+ ).start()
54
+
55
+ _initialized = True
56
+ logger.info("SecureScout IAST agent initialized successfully.")
57
+
58
+ except Exception as e:
59
+ # Guarantee absolute fail-safety for the customer app on startup
60
+ logger.error(f"Failed to initialize SecureScout IAST agent: {e}", exc_info=True)
@@ -0,0 +1,10 @@
1
+ import os
2
+
3
+ # Base API endpoint for the SecureScout backend
4
+ DEFAULT_BACKEND_URL = os.environ.get("SECURESCOUT_BACKEND_URL", "https://api.getsecurescout.com")
5
+
6
+ # Maximum request body size (in bytes) we are willing to buffer for taint parsing (1MB)
7
+ MAX_BODY_SIZE_BYTES = 1_000_000
8
+
9
+ # Minimum string length required to register a taint value to prevent single-character false positives
10
+ TAINT_MIN_LENGTH = 6
@@ -0,0 +1,142 @@
1
+ import uuid
2
+ import logging
3
+ from typing import Any
4
+ from urllib.parse import parse_qsl
5
+ from http.cookies import SimpleCookie
6
+
7
+ from securescout_iast.taint import (
8
+ init_request_taint_registry,
9
+ register_taint,
10
+ register_endpoint,
11
+ clear_thread_taint_registry
12
+ )
13
+
14
+ logger = logging.getLogger("securescout_iast")
15
+
16
+
17
+ class SecureScoutIastMiddleware:
18
+ """
19
+ Pure ASGI Middleware that performs wire-level interception of query parameters,
20
+ headers, cookies, and body inputs. Stores observed taint data in task-local ContextVars
21
+ without modifying Starlette's Request objects or customer application types.
22
+ """
23
+ def __init__(self, app):
24
+ self.app = app
25
+
26
+ async def __call__(self, scope, receive, send) -> None:
27
+ if scope["type"] != "http":
28
+ await self.app(scope, receive, send)
29
+ return
30
+
31
+ # 1. Initialize context-isolated registry and request ID
32
+ request_id = str(uuid.uuid4())
33
+ scope["securescout_request_id"] = request_id
34
+ init_request_taint_registry()
35
+
36
+ # Register endpoint context
37
+ endpoint = f"{scope.get('method', 'GET')} {scope.get('path', '/')}"
38
+ register_endpoint(request_id, endpoint)
39
+
40
+ try:
41
+ # 2. Parse raw query string from scope
42
+ query_bytes = scope.get("query_string", b"")
43
+ try:
44
+ query_string = query_bytes.decode("utf-8", errors="ignore")
45
+ if query_string:
46
+ for key, val in parse_qsl(query_string, keep_blank_values=True):
47
+ if len(val) >= 6:
48
+ register_taint(val, source="query_param", field_name=key, request_id=request_id)
49
+ except Exception as e:
50
+ logger.debug(f"Failed to parse query string: {e}")
51
+
52
+ # 3. Parse cookies and client headers from scope headers list
53
+ for raw_key, raw_val in scope.get("headers", []):
54
+ try:
55
+ key = raw_key.decode("latin-1").lower()
56
+ val = raw_val.decode("latin-1")
57
+
58
+ if key == "cookie":
59
+ cookie = SimpleCookie()
60
+ cookie.load(val)
61
+ for c_key, morsel in cookie.items():
62
+ if len(morsel.value) >= 6:
63
+ register_taint(morsel.value, source="cookie", field_name=c_key, request_id=request_id)
64
+ elif key in {"referer", "user-agent", "x-forwarded-for"}:
65
+ if len(val) >= 6:
66
+ register_taint(val, source="header", field_name=key, request_id=request_id)
67
+ except Exception as e:
68
+ logger.debug(f"Failed to parse header {raw_key}: {e}")
69
+
70
+ # 4. Content-Length pre-check to prevent buffering huge bodies
71
+ content_length = 0
72
+ for rk, rv in scope.get("headers", []):
73
+ if rk.decode("latin-1").lower() == "content-length":
74
+ try:
75
+ content_length = int(rv.decode("latin-1"))
76
+ except ValueError:
77
+ pass
78
+ break
79
+
80
+ max_buffer_size = 1_000_000 # 1MB size limit
81
+ should_buffer_body = content_length <= max_buffer_size
82
+
83
+ body_bytes = bytearray()
84
+ buffer_overflow = False
85
+
86
+ # Wrap receive function to capture request body chunks safely
87
+ async def wrapped_receive():
88
+ nonlocal buffer_overflow
89
+ message = await receive()
90
+ if should_buffer_body and not buffer_overflow and message.get("type") == "http.request":
91
+ chunk = message.get("body", b"")
92
+ if chunk:
93
+ # Enforce hard cap during streaming (e.g. if Content-Length was missing or lying)
94
+ if len(body_bytes) + len(chunk) > max_buffer_size:
95
+ buffer_overflow = True
96
+ body_bytes.clear() # Free memory immediately
97
+ logger.debug("Body size exceeded 1MB cap. Taint extraction disabled for this request.")
98
+ else:
99
+ body_bytes.extend(chunk)
100
+
101
+ # Trigger parsing only on the final chunk if no overflow occurred
102
+ if not message.get("more_body", False) and not buffer_overflow:
103
+ try:
104
+ # Extract Content-Type header from scope
105
+ content_type = ""
106
+ for rk, rv in scope.get("headers", []):
107
+ if rk.decode("latin-1").lower() == "content-type":
108
+ content_type = rv.decode("latin-1")
109
+ break
110
+
111
+ if "application/json" in content_type:
112
+ import json
113
+ parsed_json = json.loads(body_bytes.decode("utf-8"))
114
+ _extract_json_taints(parsed_json, request_id)
115
+ elif "application/x-www-form-urlencoded" in content_type:
116
+ form_data = body_bytes.decode("utf-8")
117
+ for fk, fv in parse_qsl(form_data, keep_blank_values=True):
118
+ if len(fv) >= 6:
119
+ register_taint(fv, source="body", field_name=fk, request_id=request_id)
120
+ except Exception as e:
121
+ logger.debug(f"Failed to extract body taints: {e}")
122
+ return message
123
+
124
+ # Execute the ASGI application stack passing the wrapped receive
125
+ await self.app(scope, wrapped_receive, send)
126
+
127
+ finally:
128
+ # 5. Guaranteed cleanup of contextvars memory
129
+ clear_thread_taint_registry()
130
+
131
+
132
+ def _extract_json_taints(data: Any, request_id: str, field_name: str = "") -> None:
133
+ """Recursively walks parsed JSON payload elements to register string values >= 6 chars."""
134
+ if isinstance(data, str):
135
+ if len(data) >= 6:
136
+ register_taint(data, source="body", field_name=field_name, request_id=request_id)
137
+ elif isinstance(data, dict):
138
+ for k, v in data.items():
139
+ _extract_json_taints(v, request_id, field_name=str(k))
140
+ elif isinstance(data, list):
141
+ for idx, item in enumerate(data):
142
+ _extract_json_taints(item, request_id, field_name=f"{field_name}[{idx}]")
@@ -0,0 +1 @@
1
+ # SecureScout IAST patches package
@@ -0,0 +1,77 @@
1
+ import logging
2
+ import traceback
3
+ from typing import Any
4
+
5
+ from securescout_iast.taint import check_query_taint, get_endpoint
6
+
7
+ logger = logging.getLogger("securescout_iast")
8
+
9
+ _original_execute = None
10
+ _original_fetch = None
11
+ _original_fetchrow = None
12
+ _original_fetchval = None
13
+
14
+
15
+ def install_asyncpg_patch(reporter_callback) -> None:
16
+ """Monkey-patches asyncpg connection queries to inspect query strings."""
17
+ global _original_execute, _original_fetch, _original_fetchrow, _original_fetchval
18
+ try:
19
+ import asyncpg.connection
20
+
21
+ _original_execute = asyncpg.connection.Connection.execute
22
+ _original_fetch = asyncpg.connection.Connection.fetch
23
+ _original_fetchrow = asyncpg.connection.Connection.fetchrow
24
+ _original_fetchval = asyncpg.connection.Connection.fetchval
25
+
26
+ def _inspect_query(query: Any) -> None:
27
+ try:
28
+ query_str = query
29
+ # If query is a PreparedStatement object, get its raw query text
30
+ if hasattr(query, "get_query"):
31
+ query_str = query.get_query()
32
+
33
+ if isinstance(query_str, str):
34
+ match = check_query_taint(query_str)
35
+ if match:
36
+ stack = [
37
+ f"File \"{f.filename}\", line {f.lineno}, in {f.name}\n {f.line}"
38
+ for f in traceback.extract_stack()
39
+ if "securescout_iast" not in f.filename
40
+ ]
41
+ reporter_callback(
42
+ rule="sql_injection",
43
+ tainted_value=match["tainted_value"],
44
+ source=match["source"],
45
+ field_name=match["field_name"],
46
+ request_id=match["request_id"],
47
+ query_snippet=query_str,
48
+ stack_trace=stack,
49
+ endpoint=get_endpoint()
50
+ )
51
+ except Exception as e:
52
+ logger.debug(f"asyncpg query hook error: {e}")
53
+
54
+ async def custom_execute(self, query, *args, **kwargs):
55
+ _inspect_query(query)
56
+ return await _original_execute(self, query, *args, **kwargs)
57
+
58
+ async def custom_fetch(self, query, *args, **kwargs):
59
+ _inspect_query(query)
60
+ return await _original_fetch(self, query, *args, **kwargs)
61
+
62
+ async def custom_fetchrow(self, query, *args, **kwargs):
63
+ _inspect_query(query)
64
+ return await _original_fetchrow(self, query, *args, **kwargs)
65
+
66
+ async def custom_fetchval(self, query, *args, **kwargs):
67
+ _inspect_query(query)
68
+ return await _original_fetchval(self, query, *args, **kwargs)
69
+
70
+ asyncpg.connection.Connection.execute = custom_execute
71
+ asyncpg.connection.Connection.fetch = custom_fetch
72
+ asyncpg.connection.Connection.fetchrow = custom_fetchrow
73
+ asyncpg.connection.Connection.fetchval = custom_fetchval
74
+ logger.info("Successfully patched asyncpg driver.")
75
+
76
+ except ImportError:
77
+ logger.debug("asyncpg driver not found. Skipping patch.")
@@ -0,0 +1,84 @@
1
+ import logging
2
+ import traceback
3
+
4
+ from securescout_iast.taint import check_query_taint, get_endpoint
5
+
6
+ logger = logging.getLogger("securescout_iast")
7
+
8
+ _original_execute = None
9
+ _original_executemany = None
10
+
11
+
12
+ def install_psycopg2_patch(reporter_callback) -> None:
13
+ """Monkey-patches psycopg2 cursor execute methods to inspect query strings."""
14
+ global _original_execute, _original_executemany
15
+ try:
16
+ import psycopg2.extensions
17
+
18
+ _original_execute = psycopg2.extensions.cursor.execute
19
+ _original_executemany = psycopg2.extensions.cursor.executemany
20
+
21
+ def custom_execute(self, query, vars=None):
22
+ try:
23
+ query_str = query
24
+ if isinstance(query, bytes):
25
+ query_str = query.decode("utf-8", errors="ignore")
26
+
27
+ if isinstance(query_str, str):
28
+ match = check_query_taint(query_str)
29
+ if match:
30
+ stack = [
31
+ f"File \"{f.filename}\", line {f.lineno}, in {f.name}\n {f.line}"
32
+ for f in traceback.extract_stack()
33
+ if "securescout_iast" not in f.filename
34
+ ]
35
+ reporter_callback(
36
+ rule="sql_injection",
37
+ tainted_value=match["tainted_value"],
38
+ source=match["source"],
39
+ field_name=match["field_name"],
40
+ request_id=match["request_id"],
41
+ query_snippet=query_str,
42
+ stack_trace=stack,
43
+ endpoint=get_endpoint()
44
+ )
45
+ except Exception as e:
46
+ logger.debug(f"psycopg2 execute hook error: {e}")
47
+
48
+ return _original_execute(self, query, vars)
49
+
50
+ def custom_executemany(self, query, vars_list):
51
+ try:
52
+ query_str = query
53
+ if isinstance(query, bytes):
54
+ query_str = query.decode("utf-8", errors="ignore")
55
+
56
+ if isinstance(query_str, str):
57
+ match = check_query_taint(query_str)
58
+ if match:
59
+ stack = [
60
+ f"File \"{f.filename}\", line {f.lineno}, in {f.name}\n {f.line}"
61
+ for f in traceback.extract_stack()
62
+ if "securescout_iast" not in f.filename
63
+ ]
64
+ reporter_callback(
65
+ rule="sql_injection",
66
+ tainted_value=match["tainted_value"],
67
+ source=match["source"],
68
+ field_name=match["field_name"],
69
+ request_id=match["request_id"],
70
+ query_snippet=query_str,
71
+ stack_trace=stack,
72
+ endpoint=get_endpoint()
73
+ )
74
+ except Exception as e:
75
+ logger.debug(f"psycopg2 executemany hook error: {e}")
76
+
77
+ return _original_executemany(self, query, vars_list)
78
+
79
+ psycopg2.extensions.cursor.execute = custom_execute
80
+ psycopg2.extensions.cursor.executemany = custom_executemany
81
+ logger.info("Successfully patched psycopg2 driver.")
82
+
83
+ except ImportError:
84
+ logger.debug("psycopg2 driver not found. Skipping patch.")
@@ -0,0 +1,105 @@
1
+ import logging
2
+ import traceback
3
+ import sqlite3
4
+
5
+ from securescout_iast.taint import check_query_taint, get_endpoint
6
+
7
+ logger = logging.getLogger("securescout_iast")
8
+
9
+
10
+ class Sqlite3CursorWrapper:
11
+ """Wrapper that delegates calls to a real sqlite3.Cursor and intercepts query executions."""
12
+ def __init__(self, cursor, reporter_callback):
13
+ self._cursor = cursor
14
+ self._reporter_callback = reporter_callback
15
+
16
+ def execute(self, query, parameters=None):
17
+ try:
18
+ if isinstance(query, str):
19
+ match = check_query_taint(query)
20
+ if match:
21
+ stack = [
22
+ f"File \"{f.filename}\", line {f.lineno}, in {f.name}\n {f.line}"
23
+ for f in traceback.extract_stack()
24
+ if "securescout_iast" not in f.filename
25
+ ]
26
+ self._reporter_callback(
27
+ rule="sql_injection",
28
+ tainted_value=match["tainted_value"],
29
+ source=match["source"],
30
+ field_name=match["field_name"],
31
+ request_id=match["request_id"],
32
+ query_snippet=query,
33
+ stack_trace=stack,
34
+ endpoint=get_endpoint()
35
+ )
36
+ except Exception as e:
37
+ logger.debug(f"sqlite3 execute hook error: {e}")
38
+
39
+ if parameters is None:
40
+ return self._cursor.execute(query)
41
+ return self._cursor.execute(query, parameters)
42
+
43
+ def executemany(self, query, seq_of_parameters):
44
+ try:
45
+ if isinstance(query, str):
46
+ match = check_query_taint(query)
47
+ if match:
48
+ stack = [
49
+ f"File \"{f.filename}\", line {f.lineno}, in {f.name}\n {f.line}"
50
+ for f in traceback.extract_stack()
51
+ if "securescout_iast" not in f.filename
52
+ ]
53
+ self._reporter_callback(
54
+ rule="sql_injection",
55
+ tainted_value=match["tainted_value"],
56
+ source=match["source"],
57
+ field_name=match["field_name"],
58
+ request_id=match["request_id"],
59
+ query_snippet=query,
60
+ stack_trace=stack,
61
+ endpoint=get_endpoint()
62
+ )
63
+ except Exception as e:
64
+ logger.debug(f"sqlite3 executemany hook error: {e}")
65
+
66
+ return self._cursor.executemany(query, seq_of_parameters)
67
+
68
+ def __getattr__(self, name):
69
+ return getattr(self._cursor, name)
70
+
71
+ def __iter__(self):
72
+ return iter(self._cursor)
73
+
74
+
75
+ class Sqlite3ConnectionWrapper:
76
+ """Wrapper that delegates calls to a real sqlite3.Connection and wraps returned cursors."""
77
+ def __init__(self, connection, reporter_callback):
78
+ self._connection = connection
79
+ self._reporter_callback = reporter_callback
80
+
81
+ def cursor(self, *args, **kwargs):
82
+ real_cursor = self._connection.cursor(*args, **kwargs)
83
+ return Sqlite3CursorWrapper(real_cursor, self._reporter_callback)
84
+
85
+ def __enter__(self):
86
+ self._connection.__enter__()
87
+ return self
88
+
89
+ def __exit__(self, exc_type, exc_val, exc_tb):
90
+ return self._connection.__exit__(exc_type, exc_val, exc_tb)
91
+
92
+ def __getattr__(self, name):
93
+ return getattr(self._connection, name)
94
+
95
+
96
+ def install_sqlite3_patch(reporter_callback) -> None:
97
+ """Monkey-patches the sqlite3.connect module function to return wrapped connections."""
98
+ _original_connect = sqlite3.connect
99
+
100
+ def custom_connect(*args, **kwargs):
101
+ real_conn = _original_connect(*args, **kwargs)
102
+ return Sqlite3ConnectionWrapper(real_conn, reporter_callback)
103
+
104
+ sqlite3.connect = custom_connect
105
+ logger.info("Successfully installed sqlite3 wrapper patch.")
@@ -0,0 +1,163 @@
1
+ import sys
2
+ import queue
3
+ import threading
4
+ import time
5
+ import json
6
+ import logging
7
+ import urllib.request
8
+ from typing import List, Optional
9
+
10
+ logger = logging.getLogger("securescout_iast")
11
+
12
+ # Enforce a hard queue cap to prevent memory leaks during backend outages
13
+ _finding_queue: queue.Queue = queue.Queue(maxsize=500)
14
+ _worker_thread: Optional[threading.Thread] = None
15
+ _api_key: str = ""
16
+ _project_id: str = ""
17
+ _backend_url: str = "https://api.getsecurescout.com"
18
+ _running: bool = False
19
+
20
+ # Failure / Backoff tracking
21
+ _consecutive_failures: int = 0
22
+ _backoff_until: float = 0.0
23
+
24
+
25
+ def init_reporter(api_key: str, project_id: str, backend_url: str = "https://api.getsecurescout.com") -> None:
26
+ """Initializes the background daemon worker and registers config attributes."""
27
+ global _api_key, _project_id, _backend_url, _worker_thread, _running
28
+ _api_key = api_key
29
+ _project_id = project_id
30
+ _backend_url = backend_url.rstrip("/")
31
+
32
+ if not _running:
33
+ _running = True
34
+ _worker_thread = threading.Thread(target=_reporter_worker, daemon=True)
35
+ _worker_thread.start()
36
+ logger.info("SecureScout IAST background reporter initialized.")
37
+
38
+
39
+ def queue_finding(
40
+ rule: str,
41
+ tainted_value: str,
42
+ source: str,
43
+ field_name: str,
44
+ request_id: str,
45
+ query_snippet: str,
46
+ stack_trace: List[str],
47
+ endpoint: str
48
+ ) -> None:
49
+ """
50
+ Callback function queued by database patches when a query matches a tainted string.
51
+ Drops findings on queue overflow to preserve memory limits.
52
+ """
53
+ finding = {
54
+ "rule": rule,
55
+ "tainted_source": f"{source}:{field_name}" if field_name else source,
56
+ "query_snippet": query_snippet,
57
+ "endpoint": endpoint,
58
+ "stack_trace": stack_trace,
59
+ "request_id": request_id,
60
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
61
+ }
62
+
63
+ try:
64
+ # Non-blocking put to avoid hanging the customer request thread on overflow
65
+ _finding_queue.put_nowait(finding)
66
+ except queue.Full:
67
+ logger.warning("SecureScout IAST telemetry queue is full. Dropping finding report.")
68
+
69
+
70
+ def send_heartbeat(framework: str = "fastapi") -> bool:
71
+ """Transmits connection heartbeat payload to the backend."""
72
+ url = f"{_backend_url}/v1/iast/heartbeat"
73
+ payload = {
74
+ "project_id": _project_id,
75
+ "app_metadata": {
76
+ "agent": "securescout-iast-python",
77
+ "framework": framework,
78
+ "python_version": sys.version.split()[0]
79
+ }
80
+ }
81
+
82
+ req = urllib.request.Request(
83
+ url,
84
+ data=json.dumps(payload).encode("utf-8"),
85
+ headers={
86
+ "Content-Type": "application/json",
87
+ "x-api-key": _api_key,
88
+ "User-Agent": "SecureScout-IAST-Agent/1.0"
89
+ },
90
+ method="POST"
91
+ )
92
+
93
+ try:
94
+ with urllib.request.urlopen(req, timeout=5) as response:
95
+ return response.status == 200
96
+ except Exception as e:
97
+ logger.debug(f"Failed to transmit IAST heartbeat: {e}")
98
+ return False
99
+
100
+
101
+ def _reporter_worker() -> None:
102
+ """Daemon thread loop that reads from the queue and aggregates batches of findings."""
103
+ global _running, _backoff_until
104
+ while _running:
105
+ # If backing off, sleep briefly and skip loop to avoid hammering the backend
106
+ if time.time() < _backoff_until:
107
+ time.sleep(5)
108
+ continue
109
+
110
+ batch = []
111
+ try:
112
+ # Block up to 5 seconds waiting for a queued finding
113
+ item = _finding_queue.get(timeout=5)
114
+ batch.append(item)
115
+
116
+ # Drain up to 10 additional findings currently in the queue
117
+ while len(batch) < 10:
118
+ try:
119
+ batch.append(_finding_queue.get_nowait())
120
+ except queue.Empty:
121
+ break
122
+ except queue.Empty:
123
+ pass
124
+
125
+ if batch:
126
+ _send_batch(batch)
127
+
128
+
129
+ def _send_batch(batch: List[dict]) -> bool:
130
+ """Sends aggregated payload batch to the IAST findings endpoint. Implements backoff."""
131
+ global _consecutive_failures, _backoff_until
132
+ url = f"{_backend_url}/v1/iast/findings"
133
+ payload = {
134
+ "project_id": _project_id,
135
+ "findings": batch
136
+ }
137
+
138
+ req = urllib.request.Request(
139
+ url,
140
+ data=json.dumps(payload).encode("utf-8"),
141
+ headers={
142
+ "Content-Type": "application/json",
143
+ "x-api-key": _api_key,
144
+ "User-Agent": "SecureScout-IAST-Agent/1.0"
145
+ },
146
+ method="POST"
147
+ )
148
+
149
+ try:
150
+ with urllib.request.urlopen(req, timeout=5) as response:
151
+ if response.status == 202:
152
+ _consecutive_failures = 0
153
+ return True
154
+ else:
155
+ logger.debug(f"SecureScout IAST findings ingestion rejected with status code: {response.status}")
156
+ except Exception as e:
157
+ logger.debug(f"Failed to transmit IAST findings payload: {e}")
158
+
159
+ _consecutive_failures += 1
160
+ if _consecutive_failures >= 3:
161
+ _backoff_until = time.time() + 30 # Back off for 30 seconds
162
+ logger.debug("SecureScout IAST reporter detected 3+ consecutive failures. Backing off for 30s.")
163
+ return False
@@ -0,0 +1,174 @@
1
+ import contextvars
2
+ from typing import Optional, List, Any, Dict, Set
3
+
4
+ # ContextVar storing a dictionary of: raw_tainted_string -> metadata_dict
5
+ _taint_registry: contextvars.ContextVar[Dict[str, dict]] = contextvars.ContextVar("securescout_iast_registry")
6
+
7
+ # ContextVar storing the HTTP endpoint string (e.g. "GET /api/users") for the request context
8
+ _endpoint_var: contextvars.ContextVar[str] = contextvars.ContextVar("securescout_iast_endpoint", default="unknown")
9
+
10
+
11
+ def init_request_taint_registry() -> Dict[str, dict]:
12
+ """Initializes a fresh, task-isolated metadata dict for the current request context."""
13
+ registry = {}
14
+ _taint_registry.set(registry)
15
+ return registry
16
+
17
+
18
+ def register_taint(value: str, source: str, field_name: str, request_id: str) -> None:
19
+ """Registers a tainted string value and its provenance metadata."""
20
+ if not value:
21
+ return
22
+ try:
23
+ registry = _taint_registry.get()
24
+ except LookupError:
25
+ registry = init_request_taint_registry()
26
+
27
+ registry[value] = {
28
+ "source": source,
29
+ "field_name": field_name,
30
+ "request_id": request_id
31
+ }
32
+
33
+
34
+ def get_taint_metadata(value: str) -> Optional[dict]:
35
+ """Retrieves provenance metadata for a given tainted string value."""
36
+ try:
37
+ return _taint_registry.get().get(value)
38
+ except LookupError:
39
+ return None
40
+
41
+
42
+ def clear_thread_taint_registry() -> None:
43
+ """Clears task-local metadata dict to release memory."""
44
+ try:
45
+ _taint_registry.get().clear()
46
+ except Exception:
47
+ pass
48
+
49
+
50
+ def check_query_taint(query: str) -> Optional[dict]:
51
+ """
52
+ Scans a query string for any registered tainted values.
53
+ Returns the metadata dict of the matched taint if found.
54
+ """
55
+ try:
56
+ registry = _taint_registry.get()
57
+ except LookupError:
58
+ return None
59
+
60
+ for tainted_val, meta in registry.items():
61
+ if tainted_val in query:
62
+ return {
63
+ "tainted_value": tainted_val,
64
+ "source": meta["source"],
65
+ "field_name": meta["field_name"],
66
+ "request_id": meta["request_id"]
67
+ }
68
+ return None
69
+
70
+
71
+ def register_endpoint(request_id: str, endpoint: str) -> None:
72
+ """Registers the HTTP endpoint (method + path) for the current request context."""
73
+ _endpoint_var.set(endpoint)
74
+
75
+
76
+ def get_endpoint() -> str:
77
+ """Retrieves the HTTP endpoint associated with the current request context."""
78
+ try:
79
+ return _endpoint_var.get()
80
+ except LookupError:
81
+ return "unknown"
82
+
83
+
84
+ class TaintedStr(str):
85
+ """
86
+ A subclass of str that carries provenance metadata indicating
87
+ it originated from an untrusted source.
88
+ """
89
+ def __new__(
90
+ cls,
91
+ value: Any,
92
+ source: Optional[str] = None,
93
+ field_name: Optional[str] = None,
94
+ request_id: Optional[str] = None
95
+ ):
96
+ obj = str.__new__(cls, value)
97
+ obj.source = source
98
+ obj.field_name = field_name
99
+ obj.request_id = request_id
100
+ return obj
101
+
102
+ def _clone(self, new_value: str) -> "TaintedStr":
103
+ """Clones the taint metadata to a new string value."""
104
+ return TaintedStr(
105
+ new_value,
106
+ source=self.source,
107
+ field_name=self.field_name,
108
+ request_id=self.request_id
109
+ )
110
+
111
+ def __add__(self, other: Any) -> "TaintedStr":
112
+ res = super().__add__(str(other))
113
+ return self._clone(res)
114
+
115
+ def __radd__(self, other: Any) -> "TaintedStr":
116
+ res = str(other) + str(self)
117
+ return self._clone(res)
118
+
119
+ def __mod__(self, other: Any) -> "TaintedStr":
120
+ res = super().__mod__(other)
121
+ return self._clone(res)
122
+
123
+ def __rmod__(self, other: Any) -> "TaintedStr":
124
+ res = str(other) % str(self)
125
+ return self._clone(res)
126
+
127
+ def __getitem__(self, index: Any) -> "TaintedStr":
128
+ res = super().__getitem__(index)
129
+ return self._clone(res)
130
+
131
+ def __format__(self, format_spec: str) -> str:
132
+ res = super().__format__(format_spec)
133
+ # Register in task-local storage if the tainted string is long enough
134
+ # to avoid false positives on common short substrings (e.g. "id", "abc", "1")
135
+ if len(self) >= 6:
136
+ register_taint(str(self), source=self.source or "formatted", field_name=self.field_name or "unknown", request_id=self.request_id or "unknown")
137
+ return res
138
+
139
+ def replace(self, old: str, new: str, count: int = -1) -> "TaintedStr":
140
+ res = super().replace(old, new, count)
141
+ return self._clone(res)
142
+
143
+ def split(self, sep: Optional[str] = None, maxsplit: int = -1) -> List["TaintedStr"]:
144
+ res = super().split(sep, maxsplit)
145
+ return [self._clone(item) for item in res]
146
+
147
+ def strip(self, chars: Optional[str] = None) -> "TaintedStr":
148
+ res = super().strip(chars)
149
+ return self._clone(res)
150
+
151
+ def lstrip(self, chars: Optional[str] = None) -> "TaintedStr":
152
+ res = super().lstrip(chars)
153
+ return self._clone(res)
154
+
155
+ def rstrip(self, chars: Optional[str] = None) -> "TaintedStr":
156
+ res = super().rstrip(chars)
157
+ return self._clone(res)
158
+
159
+ def lower(self) -> "TaintedStr":
160
+ res = super().lower()
161
+ return self._clone(res)
162
+
163
+ def upper(self) -> "TaintedStr":
164
+ res = super().upper()
165
+ return self._clone(res)
166
+
167
+ def join(self, iterable: Any) -> "TaintedStr":
168
+ res = super().join(iterable)
169
+ return self._clone(res)
170
+
171
+
172
+ def is_tainted(value: Any) -> bool:
173
+ """Helper function to check if a value is a TaintedStr."""
174
+ return isinstance(value, TaintedStr)
@@ -0,0 +1,69 @@
1
+ Metadata-Version: 2.4
2
+ Name: securescout-iast
3
+ Version: 0.1.0
4
+ Summary: Interactive Application Security Testing (IAST) runtime agent for Python
5
+ License: MIT
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: License :: OSI Approved :: MIT License
8
+ Classifier: Operating System :: OS Independent
9
+ Classifier: Topic :: Security
10
+ Classifier: Framework :: FastAPI
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+
14
+ # securescout-iast
15
+
16
+ Interactive Application Security Testing (IAST) runtime agent for Python web applications. Detects SQL injection vulnerabilities in real time by tracing untrusted request data as it flows into database queries during normal application traffic.
17
+
18
+ ## How it works
19
+
20
+ `securescout-iast` tags incoming request data (query parameters, headers, cookies, and JSON/form body) at the ASGI layer, then watches for that data appearing in raw SQL execute calls. If untrusted input reaches a database query without being safely parameterized, a finding is reported to your SecureScout dashboard — confirmed by actual runtime execution, not static guesswork.
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ pip install securescout-iast
26
+ ```
27
+
28
+ ## Quick start (FastAPI / Starlette)
29
+
30
+ ```python
31
+ from fastapi import FastAPI
32
+ from securescout_iast import SecureScoutIastMiddleware, init
33
+
34
+ app = FastAPI()
35
+
36
+ init(
37
+ api_key="ssk_live_your_api_key",
38
+ project_id="your-project-id",
39
+ )
40
+ app.add_middleware(SecureScoutIastMiddleware)
41
+ ```
42
+
43
+ Get your API key and project ID from **Settings → API Keys** and your project's **Runtime (IAST)** tab in the SecureScout dashboard.
44
+
45
+ ## Supported database drivers
46
+
47
+ - `psycopg2` (sync PostgreSQL)
48
+ - `asyncpg` (async PostgreSQL, including async SQLAlchemy)
49
+
50
+ Drivers are detected automatically. If a driver isn't installed in your environment, that patch is silently skipped — no errors, no extra dependencies pulled in.
51
+
52
+ ## Detected vulnerability classes
53
+
54
+ - SQL Injection (CWE-89) — v1
55
+
56
+ ## Safety guarantees
57
+
58
+ - `init()` never raises. Misconfiguration or network issues degrade to a silent no-op, never a crash.
59
+ - Your request and database driver behavior are never modified — the agent only observes.
60
+ - Request bodies over 1MB are not buffered for taint analysis (still passed through to your app unmodified).
61
+ - No third-party dependencies. Pure standard library.
62
+
63
+ ## Privacy
64
+
65
+ This agent inspects request data and SQL query text locally, within your application process, to detect taint matches. Only confirmed findings (rule type, query snippet, stack trace, endpoint) are sent to SecureScout — never raw request bodies or full traffic.
66
+
67
+ ## License
68
+
69
+ MIT
@@ -0,0 +1,16 @@
1
+ README.md
2
+ pyproject.toml
3
+ securescout_iast/__init__.py
4
+ securescout_iast/config.py
5
+ securescout_iast/middleware.py
6
+ securescout_iast/reporter.py
7
+ securescout_iast/taint.py
8
+ securescout_iast.egg-info/PKG-INFO
9
+ securescout_iast.egg-info/SOURCES.txt
10
+ securescout_iast.egg-info/dependency_links.txt
11
+ securescout_iast.egg-info/top_level.txt
12
+ securescout_iast/patches/__init__.py
13
+ securescout_iast/patches/asyncpg_patch.py
14
+ securescout_iast/patches/psycopg2_patch.py
15
+ securescout_iast/patches/sqlite3_patch.py
16
+ tests/test_taint_propagation.py
@@ -0,0 +1 @@
1
+ securescout_iast
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,137 @@
1
+ import sys
2
+ import types
3
+ import pytest
4
+ import contextvars
5
+
6
+ from securescout_iast.taint import (
7
+ TaintedStr,
8
+ register_taint,
9
+ check_query_taint,
10
+ init_request_taint_registry,
11
+ get_endpoint,
12
+ register_endpoint
13
+ )
14
+ from securescout_iast.patches.psycopg2_patch import install_psycopg2_patch
15
+
16
+
17
+ def test_tainted_str_propagation():
18
+ """Verifies that TaintedStr propagates its type and metadata through common string operations."""
19
+ t = TaintedStr("O-Malley", source="query_param", field_name="username", request_id="req-123")
20
+
21
+ # Concatenation (+)
22
+ res_add = "SELECT * FROM users WHERE name = '" + t + "'"
23
+ assert isinstance(res_add, TaintedStr)
24
+ assert res_add.source == "query_param"
25
+ assert res_add.field_name == "username"
26
+ assert res_add.request_id == "req-123"
27
+
28
+ # Reflected Concat
29
+ res_radd = t + " LIMIT 1"
30
+ assert isinstance(res_radd, TaintedStr)
31
+
32
+ # Replace
33
+ res_rep = t.replace("O-", "O")
34
+ assert isinstance(res_rep, TaintedStr)
35
+ assert res_rep == "OMalley"
36
+
37
+ # Strip
38
+ t_padded = TaintedStr(" O-Malley ", source="query_param", field_name="username", request_id="req-123")
39
+ res_strip = t_padded.strip()
40
+ assert isinstance(res_strip, TaintedStr)
41
+ assert res_strip == "O-Malley"
42
+
43
+ # Slicing (__getitem__)
44
+ res_slice = t[0:3]
45
+ assert isinstance(res_slice, TaintedStr)
46
+ assert res_slice == "O-M"
47
+
48
+ # Modulo format (%)
49
+ res_mod = "SELECT * FROM users WHERE name = '%s'" % t
50
+ assert isinstance(res_mod, TaintedStr)
51
+ assert "O-Malley" in res_mod
52
+
53
+
54
+ def test_registry_matching_and_isolation():
55
+ """Verifies that register_taint and check_query_taint are context-isolated."""
56
+ # 1. Simulate Request A
57
+ ctx_a = contextvars.copy_context()
58
+ def run_a():
59
+ init_request_taint_registry()
60
+ register_taint("O-Malley", source="query_param", field_name="name", request_id="a")
61
+
62
+ # Match expected
63
+ match = check_query_taint("SELECT * FROM users WHERE name = 'O-Malley'")
64
+ assert match is not None
65
+ assert match["request_id"] == "a"
66
+
67
+ # Clean query
68
+ assert check_query_taint("SELECT * FROM users WHERE name = %s") is None
69
+
70
+ ctx_a.run(run_a)
71
+
72
+ # 2. Simulate Request B (should be isolated from Request A)
73
+ ctx_b = contextvars.copy_context()
74
+ def run_b():
75
+ init_request_taint_registry()
76
+ # Should not see Request A's taint
77
+ assert check_query_taint("SELECT * FROM users WHERE name = 'O-Malley'") is None
78
+
79
+ register_taint("admin_user", source="body", field_name="role", request_id="b")
80
+ match = check_query_taint("UPDATE users SET role = 'admin_user'")
81
+ assert match is not None
82
+ assert match["request_id"] == "b"
83
+
84
+ ctx_b.run(run_b)
85
+
86
+
87
+ def test_psycopg2_driver_mock_patching():
88
+ """Mocks psycopg2 class structures and tests executing the patcher pipeline."""
89
+ # 1. Construct a mock psycopg2 module
90
+ mock_psycopg2 = types.ModuleType("psycopg2")
91
+ mock_psycopg2.extensions = types.ModuleType("psycopg2.extensions")
92
+
93
+ class DummyCursor:
94
+ def execute(self, query, vars=None):
95
+ return "executed"
96
+
97
+ def executemany(self, query, vars_list):
98
+ return "executed many"
99
+
100
+ mock_psycopg2.extensions.cursor = DummyCursor
101
+ sys.modules["psycopg2"] = mock_psycopg2
102
+ sys.modules["psycopg2.extensions"] = mock_psycopg2.extensions
103
+
104
+ # 2. Install the patch
105
+ reporter_calls = []
106
+ def dummy_reporter(**kwargs):
107
+ reporter_calls.append(kwargs)
108
+
109
+ install_psycopg2_patch(dummy_reporter)
110
+
111
+ # 3. Simulate context request
112
+ ctx = contextvars.copy_context()
113
+ def run_test():
114
+ init_request_taint_registry()
115
+ register_endpoint("req-789", "POST /api/login")
116
+ register_taint("compromised_value", source="query_param", field_name="id", request_id="req-789")
117
+
118
+ cursor = mock_psycopg2.extensions.cursor()
119
+
120
+ # Vulnerable call: concatenated tainted string
121
+ cursor.execute("SELECT * FROM items WHERE id = 'compromised_value'")
122
+ assert len(reporter_calls) == 1
123
+ assert reporter_calls[0]["rule"] == "sql_injection"
124
+ assert reporter_calls[0]["tainted_value"] == "compromised_value"
125
+ assert reporter_calls[0]["endpoint"] == "POST /api/login"
126
+ assert reporter_calls[0]["source"] == "query_param"
127
+
128
+ # Safe call: parameterized query (no match)
129
+ reporter_calls.clear()
130
+ cursor.execute("SELECT * FROM items WHERE id = %s", ("compromised_value",))
131
+ assert len(reporter_calls) == 0
132
+
133
+ ctx.run(run_test)
134
+
135
+ # Clean up mock from sys.modules
136
+ sys.modules.pop("psycopg2", None)
137
+ sys.modules.pop("psycopg2.extensions", None)