src2sink 1.0.0__py3-none-any.whl
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.
- src2sink/__init__.py +1 -0
- src2sink/aggregators/__init__.py +3 -0
- src2sink/aggregators/api_client_discovery.py +259 -0
- src2sink/aggregators/auth_cards.py +116 -0
- src2sink/aggregators/crypto_cards.py +132 -0
- src2sink/aggregators/data_stores.py +125 -0
- src2sink/aggregators/graphs.py +36 -0
- src2sink/aggregators/index_v2.py +138 -0
- src2sink/aggregators/library_source_map.py +188 -0
- src2sink/aggregators/openapi_discovery.py +113 -0
- src2sink/aggregators/openapi_edges.py +30 -0
- src2sink/aggregators/openapi_match.py +81 -0
- src2sink/aggregators/openapi_models.py +25 -0
- src2sink/aggregators/openapi_report.py +75 -0
- src2sink/aggregators/payload_producers.py +369 -0
- src2sink/aggregators/phase3.py +30 -0
- src2sink/aggregators/pii_cross_repo.py +283 -0
- src2sink/aggregators/pii_flow_v2.py +118 -0
- src2sink/aggregators/pii_lifecycle.py +19 -0
- src2sink/aggregators/pii_lifecycle_report.py +121 -0
- src2sink/aggregators/pii_touchpoint_collect.py +134 -0
- src2sink/aggregators/pii_touchpoint_utils.py +96 -0
- src2sink/aggregators/queues.py +141 -0
- src2sink/aggregators/ropa.py +190 -0
- src2sink/aggregators/service_call_collect.py +240 -0
- src2sink/aggregators/service_call_index.py +31 -0
- src2sink/aggregators/service_call_models.py +17 -0
- src2sink/aggregators/service_call_report.py +118 -0
- src2sink/aggregators/service_calls.py +14 -0
- src2sink/aggregators/taint_buckets.py +74 -0
- src2sink/aggregators/taint_catalogs.py +35 -0
- src2sink/aggregators/taint_writers.py +369 -0
- src2sink/aggregators/traces_index.py +103 -0
- src2sink/build_metabase_v2.py +807 -0
- src2sink/constants.py +44 -0
- src2sink/curate_internal_libraries.py +135 -0
- src2sink/extractors/__init__.py +17 -0
- src2sink/extractors/ast_walk.py +131 -0
- src2sink/extractors/base.py +59 -0
- src2sink/extractors/config.py +224 -0
- src2sink/extractors/file_context.py +35 -0
- src2sink/extractors/go.py +3 -0
- src2sink/extractors/http_out.py +108 -0
- src2sink/extractors/java.py +3 -0
- src2sink/extractors/javascript.py +3 -0
- src2sink/extractors/kotlin.py +3 -0
- src2sink/extractors/node_factory.py +65 -0
- src2sink/extractors/patterns.py +145 -0
- src2sink/extractors/python_extractor.py +3 -0
- src2sink/extractors/regex_extractors.py +378 -0
- src2sink/extractors/ts_extractors.py +123 -0
- src2sink/extractors/unified.py +64 -0
- src2sink/graph_common.py +213 -0
- src2sink/internal_groups.py +142 -0
- src2sink/known_api_clients.py +108 -0
- src2sink/library_taint_java.py +92 -0
- src2sink/limits.py +171 -0
- src2sink/models/__init__.py +3 -0
- src2sink/models/auth_model.py +3 -0
- src2sink/models/crypto_agility.py +3 -0
- src2sink/models/pii_lifecycle.py +92 -0
- src2sink/models/ropa.py +37 -0
- src2sink/prescreen.py +93 -0
- src2sink/py.typed +0 -0
- src2sink/record_fleet_baseline.py +81 -0
- src2sink/renderers/__init__.py +3 -0
- src2sink/renderers/markdown.py +149 -0
- src2sink/renderers/mermaid.py +3 -0
- src2sink/repo_utils.py +885 -0
- src2sink/safe_paths.py +54 -0
- src2sink/sanitize.py +107 -0
- src2sink/schema.py +57 -0
- src2sink/trace.py +558 -0
- src2sink/trace_batch.py +176 -0
- src2sink/vocabulary.py +152 -0
- src2sink-1.0.0.dist-info/METADATA +355 -0
- src2sink-1.0.0.dist-info/RECORD +81 -0
- src2sink-1.0.0.dist-info/WHEEL +5 -0
- src2sink-1.0.0.dist-info/entry_points.txt +6 -0
- src2sink-1.0.0.dist-info/licenses/LICENSE +21 -0
- src2sink-1.0.0.dist-info/top_level.txt +1 -0
src2sink/constants.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Shared constants for metabase extractors (v1 + v2)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
SKIP_DIRS = {
|
|
8
|
+
".git", ".idea", ".vscode", ".gradle", ".mvn", ".pytest_cache",
|
|
9
|
+
"node_modules", "target", "build", "dist", "out", "bin",
|
|
10
|
+
"vendor", ".terraform", "__pycache__", ".cache", ".tox",
|
|
11
|
+
"coverage", ".nyc_output", ".next", ".nuxt", ".svelte-kit",
|
|
12
|
+
"tmp", "temp", ".DS_Store",
|
|
13
|
+
".tmp_gitlab_archives",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
SOURCE_EXTENSIONS = {
|
|
17
|
+
".java": "java", ".kt": "kotlin", ".scala": "scala",
|
|
18
|
+
".ts": "typescript", ".tsx": "tsx",
|
|
19
|
+
".js": "javascript", ".jsx": "javascript", ".mjs": "javascript",
|
|
20
|
+
".py": "python", ".go": "go",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
CONFIG_FILE_NAMES = {
|
|
24
|
+
"application.yml", "application.yaml", "application.properties",
|
|
25
|
+
"bootstrap.yml", "bootstrap.yaml", "bootstrap.properties",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
MAX_FILE_BYTES = 1_500_000
|
|
29
|
+
|
|
30
|
+
TEST_PATH_RX = re.compile(
|
|
31
|
+
r"(?:^|/)(?:"
|
|
32
|
+
r"test|tests|spec|specs|__tests__|fixtures?|examples?|samples?|"
|
|
33
|
+
r"sandbox|playground|local|demo|mock|mocks|stub|stubs|"
|
|
34
|
+
r"src/test|src/it|integration[-_]?test|"
|
|
35
|
+
r"qa[-_]?test|e2e"
|
|
36
|
+
r"|"
|
|
37
|
+
r"[a-z][a-zA-Z0-9]*Tests?"
|
|
38
|
+
r")(?:/|$)",
|
|
39
|
+
re.IGNORECASE,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
WEAK_ALGOS = frozenset({
|
|
43
|
+
"MD5", "MD4", "SHA1", "SHA-1", "DES", "RC4", "RC2", "BLOWFISH",
|
|
44
|
+
})
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Auto-fill internal-library taint tables from cloned library source (stdlib)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .aggregators.library_source_map import load_library_source_map
|
|
12
|
+
from .library_taint_java import render_taint_table, scan_java_public_api
|
|
13
|
+
from .repo_utils import _locate_library_source
|
|
14
|
+
|
|
15
|
+
TBD_ROW = "| _TBD_ | _TBD_ | _TBD_ | _TBD_ | _TBD_ |\n"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _extract_coordinate(lib_text: str) -> str | None:
|
|
19
|
+
"""Return the coordinate value from a library markdown table, or None."""
|
|
20
|
+
m = re.search(r"\|\s*Coordinate\s*\|\s*([^|]+)\s*\|", lib_text)
|
|
21
|
+
return m.group(1).strip() if m else None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _resolve_library_root(
|
|
25
|
+
repos_root: Path, coord: str, source_map: dict[str, Any] | None = None
|
|
26
|
+
) -> Path | None:
|
|
27
|
+
"""Resolve the on-disk source root for a library coordinate, or None.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
repos_root: Root directory holding cloned repos.
|
|
31
|
+
coord: Library coordinate to locate.
|
|
32
|
+
source_map: Optional precomputed coordinate-to-source mapping.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
The library's source directory if it exists, else None.
|
|
36
|
+
"""
|
|
37
|
+
source_rel = _locate_library_source(repos_root, coord, source_map=source_map)
|
|
38
|
+
if not source_rel:
|
|
39
|
+
return None
|
|
40
|
+
rel = source_rel.removeprefix("repos/").lstrip("/")
|
|
41
|
+
repo_root = repos_root / rel
|
|
42
|
+
return repo_root if repo_root.is_dir() else None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _replace_tbd_table(lib_text: str, table: str) -> str:
|
|
46
|
+
"""Substitute the placeholder taint table in lib_text with the given table."""
|
|
47
|
+
if TBD_ROW in lib_text:
|
|
48
|
+
return lib_text.replace(TBD_ROW, _table_body(table), count=1)
|
|
49
|
+
return re.sub(
|
|
50
|
+
r"(## Public-API taint table \(hand-curated\)\n\n"
|
|
51
|
+
r"(?:Each row.*?\n\n)?)"
|
|
52
|
+
r"\| Method signature \|.*?\| _TBD_ \| _TBD_ \| _TBD_ \| _TBD_ \| _TBD_ \|\n",
|
|
53
|
+
r"\1" + table + "\n",
|
|
54
|
+
lib_text,
|
|
55
|
+
count=1,
|
|
56
|
+
flags=re.DOTALL,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _table_body(table: str) -> str:
|
|
61
|
+
"""Return the data rows of a markdown table, dropping the header and separator."""
|
|
62
|
+
body_lines = table.strip().splitlines()
|
|
63
|
+
if len(body_lines) > 2:
|
|
64
|
+
return "\n".join(body_lines[2:]) + "\n"
|
|
65
|
+
return table
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def curate_library_file(
|
|
69
|
+
lib_path: Path,
|
|
70
|
+
repos_root: Path,
|
|
71
|
+
coord: str,
|
|
72
|
+
source_map: dict[str, Any] | None = None,
|
|
73
|
+
) -> bool:
|
|
74
|
+
"""Fill a library file's placeholder taint table from scanned source.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
lib_path: Path to the internal-library markdown file.
|
|
78
|
+
repos_root: Root directory holding cloned repos.
|
|
79
|
+
coord: Library coordinate used to locate the source.
|
|
80
|
+
source_map: Optional precomputed coordinate-to-source mapping.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
True if the file was updated, False otherwise.
|
|
84
|
+
"""
|
|
85
|
+
text = lib_path.read_text(encoding="utf-8")
|
|
86
|
+
if "_TBD_" not in text:
|
|
87
|
+
return False
|
|
88
|
+
repo_root = _resolve_library_root(repos_root, coord, source_map=source_map)
|
|
89
|
+
if not repo_root:
|
|
90
|
+
return False
|
|
91
|
+
rows = scan_java_public_api(repo_root)
|
|
92
|
+
if not rows:
|
|
93
|
+
return False
|
|
94
|
+
table = render_taint_table(rows)
|
|
95
|
+
new_text = _replace_tbd_table(text, table)
|
|
96
|
+
if new_text == text:
|
|
97
|
+
return False
|
|
98
|
+
lib_path.write_text(new_text, encoding="utf-8")
|
|
99
|
+
return True
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def main() -> int:
|
|
103
|
+
"""CLI entry point: curate placeholder taint tables across internal libraries."""
|
|
104
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
105
|
+
parser.add_argument("--repos-root", required=True)
|
|
106
|
+
parser.add_argument("--metabase-root", required=True)
|
|
107
|
+
parser.add_argument("--limit", type=int, default=30)
|
|
108
|
+
args = parser.parse_args()
|
|
109
|
+
|
|
110
|
+
repos_root = Path(args.repos_root).resolve()
|
|
111
|
+
metabase_root = Path(args.metabase_root).resolve()
|
|
112
|
+
lib_dir = metabase_root / "internal-libraries"
|
|
113
|
+
lib_dir.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
source_map = load_library_source_map(metabase_root / "library-source-map.json")
|
|
115
|
+
updated = 0
|
|
116
|
+
for path in sorted(lib_dir.glob("*.md")):
|
|
117
|
+
if path.name == "INDEX.md":
|
|
118
|
+
continue
|
|
119
|
+
text = path.read_text(encoding="utf-8")
|
|
120
|
+
if "_TBD_" not in text:
|
|
121
|
+
continue
|
|
122
|
+
coord = _extract_coordinate(text)
|
|
123
|
+
if not coord:
|
|
124
|
+
continue
|
|
125
|
+
if curate_library_file(path, repos_root, coord, source_map=source_map):
|
|
126
|
+
updated += 1
|
|
127
|
+
print(f" curated {path.name}")
|
|
128
|
+
if updated >= args.limit:
|
|
129
|
+
break
|
|
130
|
+
print(f"Updated {updated} library files")
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
if __name__ == "__main__":
|
|
135
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Per-language tree-sitter extractors (Phase 1+)."""
|
|
2
|
+
|
|
3
|
+
from .base import (
|
|
4
|
+
load_language,
|
|
5
|
+
make_parser,
|
|
6
|
+
parse_file,
|
|
7
|
+
parse_source,
|
|
8
|
+
supported_languages,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"load_language",
|
|
13
|
+
"make_parser",
|
|
14
|
+
"parse_file",
|
|
15
|
+
"parse_source",
|
|
16
|
+
"supported_languages",
|
|
17
|
+
]
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Tree-sitter AST walking helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from tree_sitter import Node
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def node_text(source: bytes, node: Node) -> str:
|
|
13
|
+
"""Decode the source bytes spanned by ``node`` to text (errors replaced)."""
|
|
14
|
+
return source[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def line_number(source: bytes, node: Node) -> int:
|
|
18
|
+
"""Return the 1-based line number where ``node`` starts."""
|
|
19
|
+
return node.start_point[0] + 1
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def walk(node: Node) -> Iterator[Node]:
|
|
23
|
+
"""Yield ``node`` and every descendant via an iterative pre-order traversal."""
|
|
24
|
+
stack = [node]
|
|
25
|
+
while stack:
|
|
26
|
+
current = stack.pop()
|
|
27
|
+
yield current
|
|
28
|
+
for child in reversed(current.children):
|
|
29
|
+
stack.append(child)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def call_name_java_kotlin(source: bytes, node: Node) -> str | None:
|
|
33
|
+
"""Return the invoked method name for a Java/Kotlin call node, or None."""
|
|
34
|
+
if node.type != "method_invocation":
|
|
35
|
+
return None
|
|
36
|
+
name_node = node.child_by_field_name("name")
|
|
37
|
+
if name_node is not None:
|
|
38
|
+
return node_text(source, name_node)
|
|
39
|
+
for child in reversed(node.children):
|
|
40
|
+
if child.type in ("identifier", "field_identifier"):
|
|
41
|
+
return node_text(source, child)
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def call_name_python(source: bytes, node: Node) -> str | None:
|
|
46
|
+
"""Return the called function/attribute name for a Python call node, or None."""
|
|
47
|
+
if node.type != "call":
|
|
48
|
+
return None
|
|
49
|
+
fn = node.child_by_field_name("function")
|
|
50
|
+
if fn is None:
|
|
51
|
+
return None
|
|
52
|
+
if fn.type == "identifier":
|
|
53
|
+
return node_text(source, fn)
|
|
54
|
+
if fn.type == "attribute":
|
|
55
|
+
attr = fn.child_by_field_name("attribute")
|
|
56
|
+
if attr is not None:
|
|
57
|
+
return node_text(source, attr)
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def call_name_js_go(source: bytes, node: Node, language: str) -> str | None:
|
|
62
|
+
"""Return the called function/member name for a JS/TS/Go call node, or None."""
|
|
63
|
+
if language in ("javascript", "typescript", "tsx"):
|
|
64
|
+
if node.type != "call_expression":
|
|
65
|
+
return None
|
|
66
|
+
fn = node.child_by_field_name("function")
|
|
67
|
+
if fn is None:
|
|
68
|
+
return None
|
|
69
|
+
if fn.type == "identifier":
|
|
70
|
+
return node_text(source, fn)
|
|
71
|
+
if fn.type == "member_expression":
|
|
72
|
+
prop = fn.child_by_field_name("property")
|
|
73
|
+
if prop is not None:
|
|
74
|
+
return node_text(source, prop)
|
|
75
|
+
if language == "go":
|
|
76
|
+
if node.type != "call_expression":
|
|
77
|
+
return None
|
|
78
|
+
fn = node.child_by_field_name("function")
|
|
79
|
+
if fn is None:
|
|
80
|
+
return None
|
|
81
|
+
if fn.type == "selector_expression":
|
|
82
|
+
field = fn.child_by_field_name("field")
|
|
83
|
+
if field is not None:
|
|
84
|
+
return node_text(source, field)
|
|
85
|
+
if fn.type == "identifier":
|
|
86
|
+
return node_text(source, fn)
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def extract_call_name(source: bytes, node: Node, language: str) -> str | None:
|
|
91
|
+
"""Dispatch to the language-specific call-name extractor for ``node``."""
|
|
92
|
+
if language in ("java", "kotlin"):
|
|
93
|
+
return call_name_java_kotlin(source, node)
|
|
94
|
+
if language == "python":
|
|
95
|
+
return call_name_python(source, node)
|
|
96
|
+
return call_name_js_go(source, node, language)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
CALL_NODE_TYPES: dict[str, frozenset[str]] = {
|
|
100
|
+
"java": frozenset({"method_invocation"}),
|
|
101
|
+
"kotlin": frozenset({"call_expression", "navigation_expression"}),
|
|
102
|
+
"python": frozenset({"call"}),
|
|
103
|
+
"javascript": frozenset({"call_expression"}),
|
|
104
|
+
"typescript": frozenset({"call_expression"}),
|
|
105
|
+
"tsx": frozenset({"call_expression"}),
|
|
106
|
+
"go": frozenset({"call_expression"}),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def iter_calls(
|
|
111
|
+
source: bytes,
|
|
112
|
+
root: Node,
|
|
113
|
+
language: str,
|
|
114
|
+
*,
|
|
115
|
+
types: frozenset[str] | None = None,
|
|
116
|
+
) -> Iterator[tuple[Node, str]]:
|
|
117
|
+
"""Yield (call node, call name) for every call-expression node in the subtree.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
source: Raw source bytes (untrusted scanned code) backing the tree.
|
|
121
|
+
root: Subtree root to walk.
|
|
122
|
+
language: Language id used to pick call node types and name extraction.
|
|
123
|
+
types: Optional override of node types treated as calls.
|
|
124
|
+
"""
|
|
125
|
+
types = types or CALL_NODE_TYPES.get(language, frozenset())
|
|
126
|
+
for node in walk(root):
|
|
127
|
+
if node.type not in types:
|
|
128
|
+
continue
|
|
129
|
+
name = extract_call_name(source, node, language)
|
|
130
|
+
if name:
|
|
131
|
+
yield node, name
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Tree-sitter parser loading and shared extractor utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from tree_sitter import Language, Parser, Tree
|
|
11
|
+
|
|
12
|
+
_LANGUAGE_MODULES: dict[str, tuple[str, str]] = {
|
|
13
|
+
"java": ("tree_sitter_java", "language"),
|
|
14
|
+
"python": ("tree_sitter_python", "language"),
|
|
15
|
+
"javascript": ("tree_sitter_javascript", "language"),
|
|
16
|
+
"go": ("tree_sitter_go", "language"),
|
|
17
|
+
"kotlin": ("tree_sitter_kotlin", "language"),
|
|
18
|
+
"typescript": ("tree_sitter_typescript", "language_typescript"),
|
|
19
|
+
"tsx": ("tree_sitter_typescript", "language_tsx"),
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_language(language_id: str) -> Language:
|
|
24
|
+
"""Load and return the tree-sitter Language for ``language_id``.
|
|
25
|
+
|
|
26
|
+
Raises KeyError for an unsupported language id.
|
|
27
|
+
"""
|
|
28
|
+
if language_id not in _LANGUAGE_MODULES:
|
|
29
|
+
raise KeyError(f"unsupported language: {language_id}")
|
|
30
|
+
module_name, attr = _LANGUAGE_MODULES[language_id]
|
|
31
|
+
mod = importlib.import_module(module_name)
|
|
32
|
+
factory = getattr(mod, attr)
|
|
33
|
+
from tree_sitter import Language
|
|
34
|
+
|
|
35
|
+
return Language(factory())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def make_parser(language_id: str) -> Parser:
|
|
39
|
+
"""Return a tree-sitter Parser configured for ``language_id``."""
|
|
40
|
+
from tree_sitter import Parser
|
|
41
|
+
|
|
42
|
+
return Parser(load_language(language_id))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_source(language_id: str, source: str | bytes) -> Tree:
|
|
46
|
+
"""Parse untrusted ``source`` into a tree-sitter Tree; the code is never executed."""
|
|
47
|
+
if isinstance(source, str):
|
|
48
|
+
source = source.encode("utf-8")
|
|
49
|
+
return make_parser(language_id).parse(source)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_file(language_id: str, path: Path) -> Tree:
|
|
53
|
+
"""Read ``path`` and parse its (untrusted) contents into a tree-sitter Tree."""
|
|
54
|
+
return parse_source(language_id, path.read_bytes())
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def supported_languages() -> frozenset[str]:
|
|
58
|
+
"""Return the set of language ids with a registered tree-sitter grammar."""
|
|
59
|
+
return frozenset(_LANGUAGE_MODULES)
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Config-file extractor — JDBC/NoSQL URLs, security keys, crypto-from-config."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from .node_factory import make_node
|
|
8
|
+
from ..schema import FlowEdge, FlowNode
|
|
9
|
+
|
|
10
|
+
# v1 security-sensitive keys (properties / YAML flattened keys)
|
|
11
|
+
# Flat properties / env style (v1 parity)
|
|
12
|
+
SECURITY_CONFIG_KEYS = re.compile(
|
|
13
|
+
r"^\s*(security\.enabled|micronaut\.security\.enabled|"
|
|
14
|
+
r"enable\.serverauth|csrf\.disabled|csrf\.enabled|"
|
|
15
|
+
r"server\.ssl\.enabled|tls\.enabled|verify[-_.]?ssl|"
|
|
16
|
+
r"insecure[_-]?skip[_-]?verify|"
|
|
17
|
+
r"cors\.allowed[\.-]origins|cors\.allow[\.-]?credentials|"
|
|
18
|
+
r"management\.endpoints\.web\.exposure\.include|"
|
|
19
|
+
r"spring\.security\.user\.password|"
|
|
20
|
+
r"jwt\.secret|jwt\.signing[\.-]?key|"
|
|
21
|
+
r"oauth2?\.client[\.-]?secret|"
|
|
22
|
+
r"actuator\.endpoints|"
|
|
23
|
+
r"debug\s*[:=]\s*true)",
|
|
24
|
+
re.IGNORECASE | re.MULTILINE,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# YAML / Helm nested keys (same semantics as dotted properties)
|
|
28
|
+
YAML_SECURITY_KEYS = re.compile(
|
|
29
|
+
r"^\s*(?:"
|
|
30
|
+
r"password|secret|signing[-_]?algorithm|client[-_]?secret|"
|
|
31
|
+
r"csrf\.(?:enabled|disabled)|"
|
|
32
|
+
r"allowed[-_]?origins|allow[-_]?credentials"
|
|
33
|
+
r")\s*:",
|
|
34
|
+
re.IGNORECASE | re.MULTILINE,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
JDBC_URL_RX = re.compile(
|
|
38
|
+
r"jdbc:(?:postgresql|mysql|mariadb|oracle|sqlserver|h2|sqlite)[^\"'\s]{8,}",
|
|
39
|
+
re.IGNORECASE,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
MONGODB_URI_RX = re.compile(
|
|
43
|
+
r"mongodb(?:\+srv)?://[^\"'\s]{8,}",
|
|
44
|
+
re.IGNORECASE,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
REDIS_URL_RX = re.compile(
|
|
48
|
+
r"rediss?://[^\"'\s]{4,}",
|
|
49
|
+
re.IGNORECASE,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
S3_BUCKET_RX = re.compile(
|
|
53
|
+
r"(?:s3://([a-z0-9.\-]{3,63})|bucket[-_.]?name\s*[=:]\s*['\"]?([a-z0-9.\-]{3,63}))",
|
|
54
|
+
re.IGNORECASE,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
CRYPTO_CONFIG_RX: list[tuple[re.Pattern[str], str]] = [
|
|
58
|
+
(re.compile(r"server\.ssl\.ciphers?\s*[=:]", re.I), "tls-cipher-suite"),
|
|
59
|
+
(re.compile(r"encryption\.algorithm\s*[=:]", re.I), "encryption-algorithm"),
|
|
60
|
+
(re.compile(r"crypto\.algorithm\s*[=:]", re.I), "encryption-algorithm"),
|
|
61
|
+
(re.compile(r"jwt\.signing[-_.]?algorithm\s*[=:]", re.I), "jwt-algorithm"),
|
|
62
|
+
(re.compile(r"spring\.ssl\.bundle", re.I), "tls-bundle"),
|
|
63
|
+
# YAML / Helm nested keys
|
|
64
|
+
(re.compile(r"^\s*ciphers\s*:", re.I | re.M), "tls-cipher-suite"),
|
|
65
|
+
(re.compile(r"^\s*signing[-_]?algorithm\s*:", re.I | re.M), "jwt-algorithm"),
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
SECRET_REF_RX: list[tuple[re.Pattern[str], str]] = [
|
|
69
|
+
(re.compile(r"aws\.secretsmanager|secretsmanager:", re.I), "aws-secrets-manager"),
|
|
70
|
+
(re.compile(r"vault[:\.]|hashicorp\.vault", re.I), "hashicorp-vault"),
|
|
71
|
+
(re.compile(r"azure\.keyvault|keyvault\.vault", re.I), "azure-key-vault"),
|
|
72
|
+
(re.compile(r"secretmanager\.googleapis|gcp\.secret", re.I), "gcp-secret-manager"),
|
|
73
|
+
(re.compile(r"kms\.key|aws\.kms|alias/", re.I), "aws-kms"),
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
DATASOURCE_KEY_RX = re.compile(
|
|
77
|
+
r"(?:spring\.datasource\.url|datasource\.url|"
|
|
78
|
+
r"quarkus\.datasource\.jdbc\.url|"
|
|
79
|
+
r"micronaut\.datasources\.[^.\s]+\.url)"
|
|
80
|
+
r"\s*[=:]\s*['\"]?([^\"'\n]+)",
|
|
81
|
+
re.IGNORECASE,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _line_of(text: str, pos: int) -> int:
|
|
86
|
+
"""Return the 1-based line number of byte offset ``pos`` in ``text``."""
|
|
87
|
+
return text.count("\n", 0, pos) + 1
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _line_slice(text: str, pos: int, end: int) -> str:
|
|
91
|
+
"""Return the trimmed full source line spanning ``pos``..``end`` (max 200 chars)."""
|
|
92
|
+
line_end = text.find("\n", end)
|
|
93
|
+
if line_end < 0:
|
|
94
|
+
line_end = len(text)
|
|
95
|
+
line_start = text.rfind("\n", 0, pos) + 1
|
|
96
|
+
return text[line_start:line_end].strip()[:200]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def extract_from_config(
|
|
100
|
+
*,
|
|
101
|
+
repo_id: str,
|
|
102
|
+
rel_path: str,
|
|
103
|
+
source: str,
|
|
104
|
+
) -> tuple[list[FlowNode], list[FlowEdge]]:
|
|
105
|
+
"""Extract config-file flow nodes (security keys, datastore URLs, crypto, secrets).
|
|
106
|
+
|
|
107
|
+
Scans untrusted config text via regex; matches are only recorded, never evaluated.
|
|
108
|
+
Returns the accumulated nodes and (currently empty) edges.
|
|
109
|
+
"""
|
|
110
|
+
nodes: list[FlowNode] = []
|
|
111
|
+
edges: list[FlowEdge] = []
|
|
112
|
+
|
|
113
|
+
for m in SECURITY_CONFIG_KEYS.finditer(source):
|
|
114
|
+
key = m.group(1)
|
|
115
|
+
nodes.append(make_node(
|
|
116
|
+
repo=repo_id,
|
|
117
|
+
file=rel_path,
|
|
118
|
+
line=_line_of(source, m.start()),
|
|
119
|
+
language="config",
|
|
120
|
+
kind="propagator",
|
|
121
|
+
family="config-security",
|
|
122
|
+
detail={"key": key, "value_line": _line_slice(source, m.start(), m.end())},
|
|
123
|
+
confidence="high",
|
|
124
|
+
))
|
|
125
|
+
|
|
126
|
+
for m in YAML_SECURITY_KEYS.finditer(source):
|
|
127
|
+
key = m.group(0).strip().split(":")[0].strip()
|
|
128
|
+
nodes.append(make_node(
|
|
129
|
+
repo=repo_id,
|
|
130
|
+
file=rel_path,
|
|
131
|
+
line=_line_of(source, m.start()),
|
|
132
|
+
language="config",
|
|
133
|
+
kind="propagator",
|
|
134
|
+
family="config-security",
|
|
135
|
+
detail={"key": key, "value_line": _line_slice(source, m.start(), m.end())},
|
|
136
|
+
confidence="medium",
|
|
137
|
+
))
|
|
138
|
+
|
|
139
|
+
for m in DATASOURCE_KEY_RX.finditer(source):
|
|
140
|
+
url = m.group(1).strip()
|
|
141
|
+
nodes.append(make_node(
|
|
142
|
+
repo=repo_id,
|
|
143
|
+
file=rel_path,
|
|
144
|
+
line=_line_of(source, m.start()),
|
|
145
|
+
language="config",
|
|
146
|
+
kind="store",
|
|
147
|
+
family="data-store",
|
|
148
|
+
detail={"vendor": "jdbc", "url": url[:200], "source": "datasource-key"},
|
|
149
|
+
confidence="high",
|
|
150
|
+
))
|
|
151
|
+
|
|
152
|
+
for pat, vendor in (
|
|
153
|
+
(JDBC_URL_RX, "jdbc"),
|
|
154
|
+
(MONGODB_URI_RX, "mongodb"),
|
|
155
|
+
(REDIS_URL_RX, "redis"),
|
|
156
|
+
):
|
|
157
|
+
for m in pat.finditer(source):
|
|
158
|
+
nodes.append(make_node(
|
|
159
|
+
repo=repo_id,
|
|
160
|
+
file=rel_path,
|
|
161
|
+
line=_line_of(source, m.start()),
|
|
162
|
+
language="config",
|
|
163
|
+
kind="store",
|
|
164
|
+
family="data-store",
|
|
165
|
+
detail={"vendor": vendor, "url": m.group(0)[:200], "source": "uri-pattern"},
|
|
166
|
+
confidence="high",
|
|
167
|
+
))
|
|
168
|
+
|
|
169
|
+
for m in S3_BUCKET_RX.finditer(source):
|
|
170
|
+
bucket = m.group(1) or m.group(2) or ""
|
|
171
|
+
if bucket:
|
|
172
|
+
nodes.append(make_node(
|
|
173
|
+
repo=repo_id,
|
|
174
|
+
file=rel_path,
|
|
175
|
+
line=_line_of(source, m.start()),
|
|
176
|
+
language="config",
|
|
177
|
+
kind="store",
|
|
178
|
+
family="data-store",
|
|
179
|
+
detail={"vendor": "s3", "bucket": bucket, "source": "bucket-ref"},
|
|
180
|
+
confidence="medium",
|
|
181
|
+
))
|
|
182
|
+
|
|
183
|
+
for pat, sub in CRYPTO_CONFIG_RX:
|
|
184
|
+
for m in pat.finditer(source):
|
|
185
|
+
nodes.append(make_node(
|
|
186
|
+
repo=repo_id,
|
|
187
|
+
file=rel_path,
|
|
188
|
+
line=_line_of(source, m.start()),
|
|
189
|
+
language="config",
|
|
190
|
+
kind="propagator",
|
|
191
|
+
family="crypto-config",
|
|
192
|
+
detail={"subkind": sub, "line": _line_slice(source, m.start(), m.end())},
|
|
193
|
+
confidence="high",
|
|
194
|
+
))
|
|
195
|
+
|
|
196
|
+
for pat, provider in SECRET_REF_RX:
|
|
197
|
+
for m in pat.finditer(source):
|
|
198
|
+
nodes.append(make_node(
|
|
199
|
+
repo=repo_id,
|
|
200
|
+
file=rel_path,
|
|
201
|
+
line=_line_of(source, m.start()),
|
|
202
|
+
language="config",
|
|
203
|
+
kind="propagator",
|
|
204
|
+
family="crypto-key-source",
|
|
205
|
+
detail={"provider": provider, "from": "config", "raw": m.group(0)[:80]},
|
|
206
|
+
confidence="medium",
|
|
207
|
+
))
|
|
208
|
+
|
|
209
|
+
return nodes, edges
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def is_config_path(path_name: str, suffix: str) -> bool:
|
|
213
|
+
"""True when a file name/suffix identifies it as a config file for this extractor."""
|
|
214
|
+
from ..constants import CONFIG_FILE_NAMES
|
|
215
|
+
|
|
216
|
+
if path_name in CONFIG_FILE_NAMES:
|
|
217
|
+
return True
|
|
218
|
+
if path_name.startswith("application-") and suffix in {".yml", ".yaml", ".properties"}:
|
|
219
|
+
return True
|
|
220
|
+
if suffix in {".properties", ".yml", ".yaml", ".env"}:
|
|
221
|
+
return True
|
|
222
|
+
if path_name in {"values.yaml", "values.yml"} or path_name.endswith(".values.yaml"):
|
|
223
|
+
return True
|
|
224
|
+
return False
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Per-file extraction state passed between regex and tree-sitter passes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from ..schema import FlowEdge, FlowNode
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class FileExtractionContext:
|
|
12
|
+
"""Mutable accumulator while scanning one source file."""
|
|
13
|
+
|
|
14
|
+
repo_id: str
|
|
15
|
+
rel_path: str
|
|
16
|
+
language: str
|
|
17
|
+
source: str
|
|
18
|
+
nodes: list[FlowNode] = field(default_factory=list)
|
|
19
|
+
edges: list[FlowEdge] = field(default_factory=list)
|
|
20
|
+
# Cross-pass links for raw-code-payload (endpoint + sql field + execution sink).
|
|
21
|
+
http_sources: list[FlowNode] = field(default_factory=list)
|
|
22
|
+
sql_execution_sinks: list[FlowNode] = field(default_factory=list)
|
|
23
|
+
raw_sql_field_lines: list[int] = field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
def line_number(self, pos: int) -> int:
|
|
26
|
+
"""Return the 1-based line number of byte offset ``pos`` in the source."""
|
|
27
|
+
return self.source.count("\n", 0, pos) + 1
|
|
28
|
+
|
|
29
|
+
def line_text_at(self, pos: int) -> str:
|
|
30
|
+
"""Full source line containing byte offset ``pos``."""
|
|
31
|
+
start = self.source.rfind("\n", 0, pos) + 1
|
|
32
|
+
end = self.source.find("\n", pos)
|
|
33
|
+
if end == -1:
|
|
34
|
+
end = len(self.source)
|
|
35
|
+
return self.source[start:end]
|