Skip to content

Commit f8227fb

Browse files
committed
feat(sca): runtime sca detection
1 parent f657ec8 commit f8227fb

12 files changed

Lines changed: 630 additions & 66 deletions

File tree

ddtrace/appsec/sca/_cve_data.json

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
"targets": [
33
{
44
"id": "vuln-001",
5-
"target": "requests.sessions:Session.send",
5+
"targets": ["requests.sessions:Session.send"],
66
"lang": "python",
77
"dependency_name": "requests",
8-
"package_versions": "<2.32.0",
8+
"package_versions": ["<2.32.0"],
99
"vulnerability": {
1010
"id": "CVE-2024-35195",
1111
"severity": "MEDIUM",
@@ -14,10 +14,10 @@
1414
},
1515
{
1616
"id": "vuln-002",
17-
"target": "urllib3._collections:HTTPHeaderDict.__setitem__",
17+
"targets": ["urllib3._collections:HTTPHeaderDict.__setitem__"],
1818
"lang": "python",
1919
"dependency_name": "urllib3",
20-
"package_versions": "<2.2.2",
20+
"package_versions": ["<2.2.2"],
2121
"vulnerability": {
2222
"id": "CVE-2024-37891",
2323
"severity": "MEDIUM",
@@ -26,10 +26,10 @@
2626
},
2727
{
2828
"id": "vuln-003",
29-
"target": "jinja2.filters:do_xmlattr",
29+
"targets": ["jinja2.filters:do_xmlattr"],
3030
"lang": "python",
3131
"dependency_name": "jinja2",
32-
"package_versions": "<3.1.3",
32+
"package_versions": ["<3.1.3"],
3333
"vulnerability": {
3434
"id": "CVE-2024-22195",
3535
"severity": "MEDIUM",
@@ -38,10 +38,10 @@
3838
},
3939
{
4040
"id": "vuln-004",
41-
"target": "jinja2.compiler:generate",
41+
"targets": ["jinja2.compiler:generate"],
4242
"lang": "python",
4343
"dependency_name": "jinja2",
44-
"package_versions": "<3.1.5",
44+
"package_versions": ["<3.1.5"],
4545
"vulnerability": {
4646
"id": "CVE-2024-56201",
4747
"severity": "HIGH",
@@ -50,10 +50,10 @@
5050
},
5151
{
5252
"id": "vuln-005",
53-
"target": "cryptography.hazmat.primitives.serialization.pkcs12:serialize_key_and_certificates",
53+
"targets": ["cryptography.hazmat.primitives.serialization.pkcs12:serialize_key_and_certificates"],
5454
"lang": "python",
5555
"dependency_name": "cryptography",
56-
"package_versions": "<42.0.4",
56+
"package_versions": ["<42.0.4"],
5757
"vulnerability": {
5858
"id": "CVE-2024-26130",
5959
"severity": "HIGH",
@@ -62,10 +62,10 @@
6262
},
6363
{
6464
"id": "vuln-006",
65-
"target": "cryptography.x509:load_pem_x509_certificate",
65+
"targets": ["cryptography.x509:load_pem_x509_certificate"],
6666
"lang": "python",
6767
"dependency_name": "cryptography",
68-
"package_versions": "<43.0.1",
68+
"package_versions": ["<43.0.1"],
6969
"vulnerability": {
7070
"id": "CVE-2024-6119",
7171
"severity": "HIGH",

ddtrace/appsec/sca/_cve_loader.py

Lines changed: 48 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def _parse_version_constraint(constraint: str) -> Optional[tuple]:
3030
Tuple of (operator_str, Version) or None if parsing fails.
3131
"""
3232
constraint = constraint.strip()
33-
for op in ("<=", ">=", "==", "!=", "<", ">"):
33+
for op in ("<=", ">=", "==", "!=", "<", ">", "="):
3434
if constraint.startswith(op):
3535
ver_str = constraint[len(op) :].strip()
3636
try:
@@ -79,9 +79,33 @@ def _version_matches(installed_version: str, constraint: str) -> bool:
7979
return installed == target
8080
elif op == "!=":
8181
return installed != target
82+
elif op == "=":
83+
return installed == target
8284
return False
8385

8486

87+
def _compound_constraint_matches(installed_version: str, compound: str) -> bool:
88+
"""Check if an installed version satisfies a compound constraint.
89+
90+
A compound constraint is a comma-separated list of sub-constraints
91+
that must ALL match (AND logic), e.g. ">=42.2.0, <42.2.28".
92+
"""
93+
parts = [p.strip() for p in compound.split(",") if p.strip()]
94+
if not parts:
95+
return False
96+
return all(_version_matches(installed_version, part) for part in parts)
97+
98+
99+
def _any_version_matches(installed_version: str, constraints: list[str]) -> bool:
100+
"""Check if an installed version satisfies ANY constraint in the list (OR logic).
101+
102+
Each constraint may itself be a compound constraint (comma-separated AND).
103+
"""
104+
if not constraints:
105+
return False
106+
return any(_compound_constraint_matches(installed_version, c) for c in constraints)
107+
108+
85109
def load_cve_targets(installed_packages: dict[str, str]) -> list[dict[str, Any]]:
86110
"""Load CVE targets from the static JSON, filtering by installed versions.
87111
@@ -110,37 +134,41 @@ def load_cve_targets(installed_packages: dict[str, str]) -> list[dict[str, Any]]
110134
# Package not installed — skip
111135
continue
112136

113-
constraint = entry.get("package_versions", "")
114-
if not _version_matches(installed_ver, constraint):
137+
constraints = entry.get("package_versions", [])
138+
if not _any_version_matches(installed_ver, constraints):
115139
log.debug(
116140
"Skipping %s: installed %s does not match %s",
117141
entry.get("vulnerability", {}).get("id", "?"),
118142
installed_ver,
119-
constraint,
143+
constraints,
120144
)
121145
continue
122146

123147
vuln = entry.get("vulnerability", {})
124148
cve_id = vuln.get("id", "")
125-
target_name = entry.get("target", "")
149+
targets = entry.get("targets", [])
126150

127-
if not target_name or not cve_id:
151+
if not targets or not cve_id:
128152
continue
129153

130-
applicable_targets.append(
131-
{
132-
"target": target_name,
133-
"dependency_name": dep_name,
134-
"cve_id": cve_id,
135-
}
136-
)
137-
log.debug(
138-
"CVE %s applies to %s %s (constraint %s)",
139-
cve_id,
140-
dep_name,
141-
installed_ver,
142-
constraint,
143-
)
154+
for target_name in targets:
155+
if not target_name:
156+
continue
157+
applicable_targets.append(
158+
{
159+
"target": target_name,
160+
"dependency_name": dep_name,
161+
"cve_id": cve_id,
162+
}
163+
)
164+
log.debug(
165+
"CVE %s applies to %s %s (constraint %s, target %s)",
166+
cve_id,
167+
dep_name,
168+
installed_ver,
169+
constraints,
170+
target_name,
171+
)
144172

145173
log.debug("Loaded %d applicable CVE targets out of %d total", len(applicable_targets), len(data.get("targets", [])))
146174
return applicable_targets

ddtrace/appsec/sca/_instrumenter.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,10 @@ def _first_instr_line(code: types.CodeType) -> int:
5252
def _get_caller_info() -> tuple[str, int, str]:
5353
"""Walk the stack to find the first user-code caller frame.
5454
55-
Returns (path, line, method) where:
55+
Returns (path, line, symbol) where:
5656
- path: relative file path of the caller
5757
- line: line number in the caller
58-
- method: function (or Class.method) name of the caller
58+
- symbol: function (or Class.method) name of the caller
5959
6060
AIDEV-NOTE: Delegates to the shared get_caller_frame_info() which uses
6161
IAST's native C get_info_frame() + rel_path(). SCA just reshapes the
@@ -66,8 +66,8 @@ def _get_caller_info() -> tuple[str, int, str]:
6666
if not file_name:
6767
return "", 0, ""
6868

69-
method = f"{class_name}.{function_name}" if class_name else (function_name or "")
70-
return file_name, line_number or 0, method
69+
symbol = f"{class_name}.{function_name}" if class_name else (function_name or "")
70+
return file_name, line_number or 0, symbol
7171
except Exception:
7272
log.debug("Failed to get caller info via get_caller_frame_info", exc_info=True)
7373
return "", 0, ""
@@ -153,23 +153,23 @@ def sca_detection_hook(qualified_name: str) -> None:
153153
writer = _get_telemetry_writer()
154154
# AIDEV-NOTE: Walk the stack to find the user-code frame that called
155155
# the vulnerable function, similar to IAST's _compute_file_line.
156-
# Reports the caller's path/line/method, not the target function's.
157-
caller_path, caller_line, caller_method = _get_caller_info()
156+
# Reports the caller's path/line/symbol, not the target function's.
157+
caller_path, caller_line, caller_symbol = _get_caller_info()
158158

159159
# AIDEV-NOTE: If the native frame walker can't find user code (e.g.,
160160
# deep wrapt/gevent stack), fall back to the target's own qualified
161161
# name so the backend knows the function was reached. Without this,
162162
# add_metadata's `if path` guard silently drops the finding and
163163
# reached stays [].
164164
if not caller_path:
165-
# Use "module.path:Class.method" as path, the method part after ":"
165+
# Use "module.path:Class.method" as path, the symbol part after ":"
166166
parts = qualified_name.split(":", 1)
167167
caller_path = parts[0] if parts else qualified_name
168-
caller_method = parts[1] if len(parts) > 1 else ""
168+
caller_symbol = parts[1] if len(parts) > 1 else ""
169169
caller_line = 0
170170

171171
for cve_id in target_info.cve_ids:
172-
writer.attach_dependency_metadata(target_info.package_name, cve_id, caller_path, caller_method, caller_line)
172+
writer.attach_dependency_metadata(target_info.package_name, cve_id, caller_path, caller_symbol, caller_line)
173173

174174
except Exception:
175175
log.debug("SCA detection hook error for %s", qualified_name, exc_info=True)

ddtrace/internal/telemetry/dependency.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ class ReachabilityMetadata:
4040
def cve_id(self) -> Optional[str]:
4141
return self.value.get("id")
4242

43-
def add_reached_entry(self, path: str, method: str, line: int) -> bool:
43+
def add_reached_entry(self, path: str, symbol: str, line: int) -> bool:
4444
"""Add a hit entry to the reached list.
4545
4646
Returns True if the entry was added, False if the list is already
@@ -49,7 +49,7 @@ def add_reached_entry(self, path: str, method: str, line: int) -> bool:
4949
reached = self.value["reached"] # always initialized at construction
5050
if len(reached) >= self._MAX_REACHED_ENTRIES:
5151
return False
52-
reached.append({"path": path, "method": method, "line": line})
52+
reached.append({"path": path, "symbol": symbol, "line": line})
5353
self._sent = False
5454
return True
5555

@@ -134,7 +134,7 @@ def mark_all_metadata_sent(self) -> None:
134134
for m in self.metadata:
135135
m._mark_sent()
136136

137-
def add_metadata(self, cve_id: str, path: str = "", method: str = "", line: int = 0) -> bool:
137+
def add_metadata(self, cve_id: str, path: str = "", symbol: str = "", line: int = 0) -> bool:
138138
"""Add or update reachability metadata for a CVE.
139139
140140
AIDEV-NOTE: RFC v3 — one metadata entry per CVE. If the CVE already
@@ -145,7 +145,7 @@ def add_metadata(self, cve_id: str, path: str = "", method: str = "", line: int
145145
Args:
146146
cve_id: CVE identifier (required).
147147
path: Caller file path (empty for registration-only).
148-
method: Caller method name (empty for registration-only).
148+
symbol: Caller symbol name (empty for registration-only).
149149
line: Caller line number (0 for registration-only).
150150
151151
Returns:
@@ -161,15 +161,15 @@ def add_metadata(self, cve_id: str, path: str = "", method: str = "", line: int
161161
for existing in self.metadata:
162162
if existing.cve_id == cve_id:
163163
# CVE already registered — add hit if call-site info provided
164-
if path and existing.add_reached_entry(path, method, line):
164+
if path and existing.add_reached_entry(path, symbol, line):
165165
return True
166166
return False
167167

168168
if len(self.metadata) >= self._MAX_METADATA_ENTRIES:
169169
return False
170170

171171
# Create new metadata entry for this CVE
172-
reached = [{"path": path, "method": method, "line": line}] if path else []
172+
reached = [{"path": path, "symbol": symbol, "line": line}] if path else []
173173
meta = ReachabilityMetadata(
174174
type="reachability",
175175
value={"id": cve_id, "reached": reached},
@@ -203,7 +203,7 @@ def attach_reachability_metadata(
203203
package_name: str,
204204
cve_id: str,
205205
path: str,
206-
method: str,
206+
symbol: str,
207207
line: int,
208208
) -> bool:
209209
"""Attach reachability metadata to an already-tracked dependency.
@@ -220,7 +220,7 @@ def attach_reachability_metadata(
220220
log.debug("Cannot attach metadata: package %r not yet tracked", package_name)
221221
return False
222222

223-
return entry.add_metadata(cve_id, path, method, line)
223+
return entry.add_metadata(cve_id, path, symbol, line)
224224

225225

226226
def register_cve_metadata(

ddtrace/internal/telemetry/dependency_tracker.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ def attach_metadata(
125125
package_name: str,
126126
cve_id: str,
127127
path: str,
128-
method: str,
128+
symbol: str,
129129
line: int,
130130
) -> bool:
131131
"""Attach reachability metadata to an imported dependency.
@@ -141,7 +141,7 @@ def attach_metadata(
141141
"""
142142
with self._lock:
143143
self._ensure_entry(package_name)
144-
return attach_reachability_metadata(self._imported_dependencies, package_name, cve_id, path, method, line)
144+
return attach_reachability_metadata(self._imported_dependencies, package_name, cve_id, path, symbol, line)
145145

146146
def register_cve(self, package_name: str, cve_id: str) -> bool:
147147
"""Register a CVE on a dependency with reached=[].

ddtrace/internal/telemetry/writer.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -349,14 +349,14 @@ def attach_dependency_metadata(
349349
package_name: str,
350350
cve_id: str,
351351
path: str,
352-
method: str,
352+
symbol: str,
353353
line: int,
354354
) -> bool:
355355
"""Attach reachability metadata to an imported dependency.
356356
357357
Delegates to DependencyTracker.attach_metadata().
358358
"""
359-
return self._dependency_tracker.attach_metadata(package_name, cve_id, path, method, line)
359+
return self._dependency_tracker.attach_metadata(package_name, cve_id, path, symbol, line)
360360

361361
def register_cve_metadata(self, package_name: str, cve_id: str) -> bool:
362362
"""Register a CVE on a dependency with reached=[].

0 commit comments

Comments
 (0)