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.
- securescout_iast-0.1.0/PKG-INFO +69 -0
- securescout_iast-0.1.0/README.md +56 -0
- securescout_iast-0.1.0/pyproject.toml +23 -0
- securescout_iast-0.1.0/securescout_iast/__init__.py +60 -0
- securescout_iast-0.1.0/securescout_iast/config.py +10 -0
- securescout_iast-0.1.0/securescout_iast/middleware.py +142 -0
- securescout_iast-0.1.0/securescout_iast/patches/__init__.py +1 -0
- securescout_iast-0.1.0/securescout_iast/patches/asyncpg_patch.py +77 -0
- securescout_iast-0.1.0/securescout_iast/patches/psycopg2_patch.py +84 -0
- securescout_iast-0.1.0/securescout_iast/patches/sqlite3_patch.py +105 -0
- securescout_iast-0.1.0/securescout_iast/reporter.py +163 -0
- securescout_iast-0.1.0/securescout_iast/taint.py +174 -0
- securescout_iast-0.1.0/securescout_iast.egg-info/PKG-INFO +69 -0
- securescout_iast-0.1.0/securescout_iast.egg-info/SOURCES.txt +16 -0
- securescout_iast-0.1.0/securescout_iast.egg-info/dependency_links.txt +1 -0
- securescout_iast-0.1.0/securescout_iast.egg-info/top_level.txt +1 -0
- securescout_iast-0.1.0/setup.cfg +4 -0
- securescout_iast-0.1.0/tests/test_taint_propagation.py +137 -0
|
@@ -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
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
securescout_iast
|
|
@@ -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)
|