Repository navigation
Expand file tree
/
Copy pathcheck_archived_tas.py
More file actions
249 lines (213 loc) · 9.63 KB
/
Copy pathcheck_archived_tas.py
File metadata and controls
249 lines (213 loc) · 9.63 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
#!/usr/bin/env python3
"""Create or refresh one GitHub issue for each archived TA in active use."""
from __future__ import annotations
import json
import os
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any
import yaml
ROOT = Path(__file__).resolve().parents[1]
DATA_SOURCES_DIR = ROOT / "data_sources"
DETECTIONS_DIR = ROOT / "detections"
SPLUNKBASE_API = "https://splunkbase.splunk.com/api/v1/app"
APP_URL_PATTERN = re.compile(
r"https?://(?:www\.)?splunkbase\.splunk\.com/app/(\d+)(?:/|\b)", re.I
)
USER_AGENT = "splunk-threat-research-team/1.0"
def yaml_files(folder: Path):
yield from sorted(folder.rglob("*.yml"))
yield from sorted(folder.rglob("*.yaml"))
def load_yaml(path: Path) -> dict[str, Any] | None:
try:
value = yaml.safe_load(path.read_text(encoding="utf-8"))
except (OSError, yaml.YAMLError) as exc:
print(f"Warning: skipping {path.relative_to(ROOT)}: {exc}", file=sys.stderr)
return None
return value if isinstance(value, dict) else None
def discover_used_tas() -> dict[str, dict[str, Any]]:
"""Return TAs grouped by app ID, including linked data sources/detections."""
data_sources: dict[str, dict[str, Any]] = {}
for path in yaml_files(DATA_SOURCES_DIR):
obj = load_yaml(path)
if not obj or not isinstance(obj.get("name"), str):
continue
data_sources[obj["name"].strip().casefold()] = {
"name": obj["name"].strip(),
"path": path.relative_to(ROOT).as_posix(),
"supported_tas": obj.get("supported_TA") or [],
"detections": [],
}
for path in yaml_files(DETECTIONS_DIR):
if (DETECTIONS_DIR / "deprecated") in path.parents:
continue
detection = load_yaml(path)
if not detection:
continue
if str(detection.get("status") or "").strip().casefold() == "deprecated":
continue
references = detection.get("data_source") or []
if isinstance(references, str):
references = [references]
if not isinstance(references, list):
continue
detection_name = str(detection.get("name") or path.stem)
detection_path = path.relative_to(ROOT).as_posix()
for reference in references:
if not isinstance(reference, str):
continue
source = data_sources.get(reference.strip().casefold())
if source:
source["detections"].append({"name": detection_name, "path": detection_path})
tas: dict[str, dict[str, Any]] = {}
for source in data_sources.values():
if not source["detections"]:
continue
for ta in source["supported_tas"]:
if not isinstance(ta, dict):
continue
url = str(ta.get("url") or "").strip()
match = APP_URL_PATTERN.search(url)
if not match:
continue
app_id = match.group(1)
name = str(ta.get("name") or f"Splunkbase app {app_id}").strip()
record = tas.setdefault(
app_id,
{
"app_id": app_id,
"name": name,
"url": f"https://splunkbase.splunk.com/app/{app_id}/",
"data_sources": {},
},
)
if record["name"].startswith("Splunkbase app ") and name:
record["name"] = name
record["data_sources"][source["path"]] = source
return tas
def get_json(url: str) -> Any:
request = urllib.request.Request(
url, headers={"User-Agent": USER_AGENT, "Accept": "application/json"}
)
with urllib.request.urlopen(request, timeout=30) as response:
return json.loads(response.read().decode("utf-8"))
def truthy(value: Any) -> bool:
if isinstance(value, str):
return value.strip().casefold() in {"true", "1", "yes", "archived"}
return value is True or value == 1
def splunkbase_archived(app_id: str) -> tuple[bool, str]:
url = f"{SPLUNKBASE_API}/{app_id}/"
payload = get_json(url)
if not isinstance(payload, dict):
raise ValueError("unexpected Splunkbase response")
archived = truthy(payload.get("is_archived")) or truthy(payload.get("archive_status"))
api_name = str(payload.get("title") or payload.get("name") or "").strip()
return archived, api_name
def github_request(method: str, url: str, token: str, body: dict[str, Any] | None = None) -> Any:
headers = {
"User-Agent": USER_AGENT,
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {token}",
"X-GitHub-Api-Version": "2022-11-28",
}
data = json.dumps(body).encode("utf-8") if body is not None else None
request = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(request, timeout=30) as response:
raw = response.read()
return json.loads(raw.decode("utf-8")) if raw else None
def list_repo_issues(repo: str, token: str) -> list[dict[str, Any]]:
"""Read open and closed issues so an old issue can be reopened in place."""
issues: list[dict[str, Any]] = []
page = 1
while True:
query = urllib.parse.urlencode({"state": "all", "per_page": 100, "page": page})
payload = github_request("GET", f"https://api.github.com/repos/{repo}/issues?{query}", token)
if not isinstance(payload, list):
raise ValueError("unexpected GitHub issues response")
issues.extend(item for item in payload if isinstance(item, dict) and "pull_request" not in item)
if len(payload) < 100:
return issues
page += 1
def issue_body(ta: dict[str, Any], repo: str, branch: str) -> str:
lines = [
f"<!-- archived-ta-tracker:{ta['app_id']} -->",
f"**Splunkbase TA:** [{ta['name']}]({ta['url']})",
"",
"This archived Splunkbase TA is referenced by data source objects used by detections.",
"",
"### Data source objects and detections",
]
for source in sorted(ta["data_sources"].values(), key=lambda item: item["name"].casefold()):
source_url = f"https://github.com/{repo}/blob/{branch}/{source['path']}"
lines.append(f"- [{source['name']}]({source_url})")
detection_items = sorted(source["detections"], key=lambda item: item["name"].casefold())
for detection in detection_items:
detection_url = f"https://github.com/{repo}/blob/{branch}/{detection['path']}"
lines.append(f" - Detection: [{detection['name']}]({detection_url})")
lines.extend(["", "_This issue is maintained automatically by the archived TA daily workflow._"])
return "\n".join(lines)
def find_existing_issue(ta: dict[str, Any], issues: list[dict[str, Any]]) -> dict[str, Any] | None:
marker = f"<!-- archived-ta-tracker:{ta['app_id']} -->"
marked = [issue for issue in issues if marker in str(issue.get("body") or "")]
if marked:
return next((issue for issue in marked if issue.get("state") == "open"), marked[0])
# Adopt a manually created issue only when its exact title and Splunkbase
# URL both match. This avoids conflating two apps that happen to share a name.
expected_title = f"{ta['name']} is archived on Splunkbase"
for issue in issues:
if issue.get("title") == expected_title and ta["url"] in str(issue.get("body") or ""):
return issue
return None
def main() -> int:
repo = os.environ.get("GITHUB_REPOSITORY", "").strip()
token = os.environ.get("GITHUB_TOKEN", "").strip()
branch = os.environ.get("GITHUB_REF_NAME", "develop").strip()
if not repo or not token:
print("GITHUB_REPOSITORY and GITHUB_TOKEN are required.", file=sys.stderr)
return 2
used_tas = discover_used_tas()
print(f"Found {len(used_tas)} distinct TAs referenced by data sources used in detections.")
issues = list_repo_issues(repo, token)
api_root = f"https://api.github.com/repos/{repo}/issues"
archived_count = 0
for app_id, ta in sorted(used_tas.items(), key=lambda item: item[1]["name"].casefold()):
try:
archived, api_name = splunkbase_archived(app_id)
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, ValueError, json.JSONDecodeError) as exc:
print(f"Warning: could not check Splunkbase app {app_id}: {exc}", file=sys.stderr)
continue
if not archived:
continue
archived_count += 1
if api_name:
ta["name"] = api_name
title = f"{ta['name']} is archived on Splunkbase"
body = issue_body(ta, repo, branch)
existing = find_existing_issue(ta, issues)
try:
if existing:
issue_number = existing["number"]
result = github_request(
"PATCH",
f"{api_root}/{issue_number}",
token,
{"title": title, "body": body, "state": "open"},
)
action = "Updated/reopened"
else:
result = github_request("POST", api_root, token, {"title": title, "body": body})
action = "Created"
issues.append(result)
print(f"{action} issue #{result['number']}: {title}")
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, KeyError, TypeError) as exc:
print(f"Error: could not save issue for app {app_id}: {exc}", file=sys.stderr)
time.sleep(0.15)
print(f"Archived TAs currently in use: {archived_count}")
return 0
if __name__ == "__main__":
raise SystemExit(main())