Skip to content

Commit 1f00900

Browse files
authored
feat(iast): overhead control engine (OCE) skeleton (#4315)
## Description Basic structure to implement the features of Overhead control engine (OCE) OCE control the number of requests to analyze, number of vulnerabilities to report and number of vulnerabilities to analyze. ## Reviewer Checklist - [ ] Title is accurate. - [ ] Description motivates each change. - [ ] No unnecessary changes were introduced in this PR. - [ ] PR cannot be broken up into smaller PRs. - [ ] Avoid breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes unless absolutely necessary. - [ ] Tests provided or description of manual testing performed is included in the code or PR. - [ ] Release note has been added for fixes and features, or else `changelog/no-changelog` label added. - [ ] All relevant GitHub issues are correctly linked. - [ ] Backports are identified and tagged with Mergifyio. - [ ] Add to milestone.
1 parent d46390f commit 1f00900

File tree

10 files changed

+378
-114
lines changed

10 files changed

+378
-114
lines changed

ddtrace/appsec/iast/__init__.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,33 @@
77
88
Then, implement the `patch()` function and its wrappers.
99
10-
When we detect a vulnerability we should report it with `ddtrace.appsec.iast.reporter.report_vulnerability` to add
11-
this information to the context and `ddtrace.appsec.iast.processor` will send this information to the backend at
12-
the end of the request
13-
"""
10+
In order to have the better performance, the Overhead control engine (OCE) helps us to control the overhead of our
11+
wrapped functions. We should create a class that inherit from `ddtrace.appsec.iast.taint_sinks._base.VulnerabilityBase`
12+
and register with `ddtrace.appsec.iast.oce`.
13+
14+
@oce.register
15+
class MyVulnerability(VulnerabilityBase):
16+
vulnerability_type = "MyVulnerability"
17+
evidence_type = "kind_of_Vulnerability"
18+
19+
Before that, we should decorate our wrappers with `wrap` method and
20+
report the vulnerabilities with `report` method. OCE will manage the number of requests, number of vulnerabilities
21+
to reduce the overhead.
22+
23+
@WeakHash.wrap
24+
def wrapped_function(wrapped, instance, args, kwargs):
25+
# type: (Callable, str, Any, Any, Any) -> Any
26+
WeakHash.report(
27+
evidence_value=evidence,
28+
)
29+
return wrapped(*args, **kwargs)
30+
""" # noqa: RST201, RST213, RST210
31+
from ddtrace.appsec.iast.overhead_control_engine import OverheadControl
32+
33+
34+
oce = OverheadControl()
35+
36+
37+
__all__ = [
38+
"oce",
39+
]
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""
2+
The Overhead control engine (OCE) is an element that by design ensures that the overhead does not go over a maximum
3+
limit. It will measure operations being executed in a request and it will deactivate detection
4+
(and therefore reduce the overhead to nearly 0) if a certain threshold is reached.
5+
"""
6+
import os
7+
import threading
8+
from typing import TYPE_CHECKING
9+
10+
11+
if TYPE_CHECKING: # pragma: no cover
12+
from typing import Set
13+
from typing import Type
14+
15+
MAX_REQUESTS = int(os.environ.get("DD_IAST_MAX_CONCURRENT_REQUESTS", 2))
16+
MAX_VULNERABILITIES_PER_REQUEST = int(os.environ.get("DD_IAST_VULNERABILITIES_PER_REQUEST", 2))
17+
18+
19+
class Operation(object):
20+
"""Common operation related to Overhead Control Engine (OCE). Every vulnerabilities/taint_sinks should inherit
21+
from this class. OCE instance calls these methods to control the overhead produced in each request.
22+
"""
23+
24+
_lock = threading.Lock()
25+
_vulnerability_quota = MAX_VULNERABILITIES_PER_REQUEST
26+
27+
@classmethod
28+
def reset(cls):
29+
cls._vulnerability_quota = MAX_VULNERABILITIES_PER_REQUEST
30+
31+
@classmethod
32+
def acquire_quota(cls):
33+
cls._lock.acquire()
34+
result = False
35+
if cls._vulnerability_quota > 0:
36+
cls._vulnerability_quota -= 1
37+
result = True
38+
cls._lock.release()
39+
return result
40+
41+
@classmethod
42+
def has_quota(cls):
43+
cls._lock.acquire()
44+
result = cls._vulnerability_quota > 0
45+
cls._lock.release()
46+
return result
47+
48+
49+
class OverheadControl(object):
50+
_request_quota = MAX_REQUESTS
51+
_enabled = False
52+
_vulnerabilities = set() # type: Set[Type[Operation]]
53+
54+
def acquire_request(self):
55+
"""Block a request's quota at start of the request.
56+
57+
TODO: Implement sampling request in this method
58+
"""
59+
if self._request_quota > 0:
60+
self._request_quota -= 1
61+
self._enabled = True
62+
63+
def release_request(self):
64+
"""increment request's quota at end of the request.
65+
66+
TODO: figure out how to check maximum requests per thread
67+
"""
68+
if self._request_quota < MAX_REQUESTS:
69+
self._request_quota += 1
70+
self._enabled = False
71+
self.vulnerabilities_reset_quota()
72+
73+
def register(self, klass):
74+
# type: (Type[Operation]) -> Type[Operation]
75+
"""Register vulnerabilities/taint_sinks. This set of elements will restart for each request."""
76+
self._vulnerabilities.add(klass)
77+
return klass
78+
79+
@property
80+
def request_has_quota(self):
81+
# type: () -> bool
82+
return self._enabled
83+
84+
def vulnerabilities_reset_quota(self):
85+
# type: () -> None
86+
for k in self._vulnerabilities:
87+
k.reset()

ddtrace/appsec/iast/processor.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import attr
55

6+
from ddtrace.appsec.iast import oce
67
from ddtrace.constants import IAST_CONTEXT_KEY
78
from ddtrace.constants import IAST_ENABLED
89
from ddtrace.constants import IAST_JSON
@@ -22,7 +23,9 @@
2223
class AppSecIastSpanProcessor(SpanProcessor):
2324
def on_span_start(self, span):
2425
# type: (Span) -> None
25-
pass
26+
if span.span_type != SpanTypes.WEB:
27+
return
28+
oce.acquire_request()
2629

2730
def on_span_finish(self, span):
2831
# type: (Span) -> None
@@ -35,9 +38,12 @@ def on_span_finish(self, span):
3538
"""
3639
if span.span_type != SpanTypes.WEB:
3740
return
41+
3842
span.set_metric(IAST_ENABLED, 1.0)
3943

4044
data = _context.get_item(IAST_CONTEXT_KEY, span=span)
4145

4246
if data:
4347
span.set_tag_str(IAST_JSON, json.dumps(attr.asdict(data)))
48+
49+
oce.release_request()

ddtrace/appsec/iast/reporter.py

Lines changed: 0 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,7 @@
11
from typing import Set
2-
from typing import TYPE_CHECKING
32

43
import attr
54

6-
from ddtrace.appsec.iast.stacktrace import get_info_frame
7-
from ddtrace.constants import IAST_CONTEXT_KEY
8-
from ddtrace.internal import _context
9-
10-
11-
if TYPE_CHECKING: # pragma: no cover
12-
13-
from typing import Text
14-
15-
from ddtrace.span import Span
16-
175

186
@attr.s(eq=False)
197
class Evidence(object):
@@ -45,31 +33,3 @@ class Source(object):
4533
class IastSpanReporter(object):
4634
sources = attr.ib(type=Set[Source], factory=set)
4735
vulnerabilities = attr.ib(type=Set[Vulnerability], factory=set)
48-
49-
50-
def report_vulnerability(span, vulnerability_type, evidence_type, evidence_value=""):
51-
# type: (Span, Text, Text, Text) -> None
52-
"""Build a IastSpanReporter instance to report it in the `AppSecIastSpanProcessor` as a string JSON"""
53-
report = _context.get_item(IAST_CONTEXT_KEY, span=span)
54-
file_name, line_number = get_info_frame()
55-
if report:
56-
report.vulnerabilities.add(
57-
Vulnerability(
58-
type=vulnerability_type,
59-
evidence=Evidence(type=evidence_type, value=evidence_value),
60-
location=Location(path=file_name, line=line_number),
61-
)
62-
)
63-
64-
else:
65-
report = IastSpanReporter(
66-
vulnerabilities={
67-
Vulnerability(
68-
type=vulnerability_type,
69-
evidence=Evidence(type=evidence_type, value=evidence_value),
70-
location=Location(path=file_name, line=line_number),
71-
)
72-
}
73-
)
74-
75-
_context.set_item(IAST_CONTEXT_KEY, report, span=span)
Lines changed: 71 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,86 @@
11
from typing import TYPE_CHECKING
22

33
from ddtrace import tracer
4+
from ddtrace.appsec.iast import oce
5+
from ddtrace.appsec.iast.overhead_control_engine import Operation
6+
from ddtrace.appsec.iast.reporter import Evidence
7+
from ddtrace.appsec.iast.reporter import IastSpanReporter
8+
from ddtrace.appsec.iast.reporter import Location
9+
from ddtrace.appsec.iast.reporter import Vulnerability
10+
from ddtrace.appsec.iast.stacktrace import get_info_frame
11+
from ddtrace.constants import IAST_CONTEXT_KEY
12+
from ddtrace.internal import _context
413
from ddtrace.internal.logger import get_logger
14+
from ddtrace.vendor.wrapt import wrap_function_wrapper
515

616

717
if TYPE_CHECKING: # pragma: no cover
818
from typing import Any
919
from typing import Callable
20+
from typing import Text
1021

1122
log = get_logger(__name__)
1223

1324

14-
def inject_span(func):
15-
# type: (Callable) -> Callable
16-
"""Get the current root Span and attach it to the wrapped function. We need the span to report the vulnerability
17-
and update the context with the report information.
18-
"""
25+
class VulnerabilityBase(Operation):
26+
vulnerability_type = ""
27+
evidence_type = ""
1928

20-
def wrapper(wrapped, instance, args, kwargs):
21-
# type: (Callable, Any, Any, Any) -> Any
22-
span = tracer.current_root_span()
23-
if span:
24-
return func(wrapped, span, instance, args, kwargs)
25-
log.warning("No root span in the current execution. Skipping IAST Taint sink.")
26-
return wrapped(*args, **kwargs)
29+
@classmethod
30+
def wrap(cls, func):
31+
# type: (Callable) -> Callable
32+
def wrapper(wrapped, instance, args, kwargs):
33+
# type: (Callable, Any, Any, Any) -> Any
34+
"""Get the current root Span and attach it to the wrapped function. We need the span to report the vulnerability
35+
and update the context with the report information.
36+
"""
37+
if oce.request_has_quota and cls.has_quota():
38+
return func(wrapped, instance, args, kwargs)
39+
else:
40+
log.debug("IAST: no vulnerability quota to analyze more sink points")
41+
return wrapped(*args, **kwargs)
2742

28-
return wrapper
43+
return wrapper
44+
45+
@classmethod
46+
def report(cls, evidence_value=""):
47+
# type: (Text) -> None
48+
"""Build a IastSpanReporter instance to report it in the `AppSecIastSpanProcessor` as a string JSON
49+
50+
TODO: check deduplications if DD_IAST_DEDUPLICATION_ENABLED is true
51+
"""
52+
if cls.acquire_quota():
53+
span = tracer.current_root_span()
54+
if not span:
55+
log.debug("No root span in the current execution. Skipping IAST taint sink.")
56+
return None
57+
58+
report = _context.get_item(IAST_CONTEXT_KEY, span=span)
59+
file_name, line_number = get_info_frame()
60+
if report:
61+
report.vulnerabilities.add(
62+
Vulnerability(
63+
type=cls.vulnerability_type,
64+
evidence=Evidence(type=cls.evidence_type, value=evidence_value),
65+
location=Location(path=file_name, line=line_number),
66+
)
67+
)
68+
69+
else:
70+
report = IastSpanReporter(
71+
vulnerabilities={
72+
Vulnerability(
73+
type=cls.vulnerability_type,
74+
evidence=Evidence(type=cls.evidence_type, value=evidence_value),
75+
location=Location(path=file_name, line=line_number),
76+
)
77+
}
78+
)
79+
_context.set_item(IAST_CONTEXT_KEY, report, span=span)
80+
81+
82+
def _wrap_function_wrapper_exception(module, name, wrapper):
83+
try:
84+
wrap_function_wrapper(module, name, wrapper)
85+
except (ImportError, AttributeError):
86+
log.debug("IAST patching. Module %s.%s not exists", module, name)

0 commit comments

Comments
 (0)