@yottameta/yotta-intel 0.1.0
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.
- package/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/NOTICE +11 -0
- package/README.md +170 -0
- package/README.zh-CN.md +181 -0
- package/SKILL.md +116 -0
- package/assets/banner.png +0 -0
- package/bin/install.js +163 -0
- package/install.sh +132 -0
- package/package.json +33 -0
- package/references/defang-rules.md +66 -0
- package/references/ioc-spec.md +66 -0
- package/references/stix-lite-spec.md +76 -0
- package/scripts/test_yotta_intel.py +520 -0
- package/scripts/yotta_intel.py +584 -0
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""yotta-intel(元情)单元测试。
|
|
4
|
+
|
|
5
|
+
运行(在技能目录内):python scripts/test_yotta_intel.py
|
|
6
|
+
或(仓库根):python yottaskills/yotta-intel/scripts/test_yotta_intel.py
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import io
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import tempfile
|
|
15
|
+
import unittest
|
|
16
|
+
|
|
17
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
18
|
+
import yotta_intel as yi
|
|
19
|
+
|
|
20
|
+
SCRIPT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "yotta_intel.py")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run_cli(*args, stdin=None):
|
|
24
|
+
"""以子进程方式运行 CLI(Windows 下也保证 UTF-8 输出)。"""
|
|
25
|
+
env = dict(os.environ)
|
|
26
|
+
env["PYTHONIOENCODING"] = "utf-8"
|
|
27
|
+
return subprocess.run(
|
|
28
|
+
[sys.executable, SCRIPT] + list(args),
|
|
29
|
+
input=stdin, capture_output=True, encoding="utf-8", errors="replace", env=env,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def types_of(records):
|
|
34
|
+
return [r["type"] for r in records]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def values_of(records, ioc_type):
|
|
38
|
+
return [r["value"] for r in records if r["type"] == ioc_type]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TestIPV4(unittest.TestCase):
|
|
42
|
+
def test_ipv4_basic(self):
|
|
43
|
+
recs = yi.extract_iocs("连接来自 203.0.113.5")
|
|
44
|
+
self.assertIn("203.0.113.5", values_of(recs, "ipv4"))
|
|
45
|
+
|
|
46
|
+
def test_ipv4_private_kept(self):
|
|
47
|
+
recs = yi.extract_iocs("内网 10.0.0.1 有异常")
|
|
48
|
+
self.assertIn("10.0.0.1", values_of(recs, "ipv4"))
|
|
49
|
+
|
|
50
|
+
def test_ipv4_invalid_octet_rejected(self):
|
|
51
|
+
recs = yi.extract_iocs("999.1.1.1 不是合法地址")
|
|
52
|
+
self.assertNotIn("999.1.1.1", values_of(recs, "ipv4"))
|
|
53
|
+
|
|
54
|
+
def test_ipv4_leading_zero_normalized(self):
|
|
55
|
+
recs = yi.extract_iocs("地址 010.0.0.1")
|
|
56
|
+
self.assertIn("10.0.0.1", values_of(recs, "ipv4"))
|
|
57
|
+
self.assertNotIn("010.0.0.1", values_of(recs, "ipv4"))
|
|
58
|
+
|
|
59
|
+
def test_ipv4_not_domain(self):
|
|
60
|
+
recs = yi.extract_iocs("1.2.3.44")
|
|
61
|
+
self.assertNotIn("1.2.3.44", values_of(recs, "domain"))
|
|
62
|
+
|
|
63
|
+
def test_ipv4_count_dedup(self):
|
|
64
|
+
recs = yi.extract_iocs("203.0.113.5\n203.0.113.5\n203.0.113.5")
|
|
65
|
+
v = [r for r in recs if r["type"] == "ipv4"]
|
|
66
|
+
self.assertEqual(len(v), 1)
|
|
67
|
+
self.assertEqual(v[0]["count"], 3)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TestIPV6(unittest.TestCase):
|
|
71
|
+
def test_ipv6_compressed(self):
|
|
72
|
+
recs = yi.extract_iocs("IPv6 2001:db8::1")
|
|
73
|
+
self.assertIn("2001:db8::1", values_of(recs, "ipv6"))
|
|
74
|
+
|
|
75
|
+
def test_ipv6_full(self):
|
|
76
|
+
recs = yi.extract_iocs("2001:0db8:0000:0000:0000:0000:0000:0001")
|
|
77
|
+
self.assertIn("2001:db8::1", values_of(recs, "ipv6"))
|
|
78
|
+
|
|
79
|
+
def test_ipv6_mapped(self):
|
|
80
|
+
recs = yi.extract_iocs("::ffff:192.168.1.1")
|
|
81
|
+
self.assertIn("::ffff:c0a8:101", values_of(recs, "ipv6"))
|
|
82
|
+
|
|
83
|
+
def test_ipv6_in_url(self):
|
|
84
|
+
recs = yi.extract_iocs("http://[2001:db8::1]/x")
|
|
85
|
+
urls = values_of(recs, "url")
|
|
86
|
+
self.assertTrue(any(u == "http://[2001:db8::1]/x" for u in urls))
|
|
87
|
+
|
|
88
|
+
def test_ipv6_invalid_rejected(self):
|
|
89
|
+
recs = yi.extract_iocs("12345:67890")
|
|
90
|
+
self.assertNotIn("12345:67890", values_of(recs, "ipv6"))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class TestDomain(unittest.TestCase):
|
|
94
|
+
def test_domain_basic(self):
|
|
95
|
+
recs = yi.extract_iocs("evil.example.com")
|
|
96
|
+
self.assertIn("evil.example.com", values_of(recs, "domain"))
|
|
97
|
+
|
|
98
|
+
def test_domain_uppercase_normalized(self):
|
|
99
|
+
recs = yi.extract_iocs("EVIL.Example.COM")
|
|
100
|
+
self.assertIn("evil.example.com", values_of(recs, "domain"))
|
|
101
|
+
|
|
102
|
+
def test_domain_trailing_dot(self):
|
|
103
|
+
recs = yi.extract_iocs("example.com.")
|
|
104
|
+
self.assertIn("example.com", values_of(recs, "domain"))
|
|
105
|
+
|
|
106
|
+
def test_domain_idn_punycode(self):
|
|
107
|
+
recs = yi.extract_iocs("例子.测试")
|
|
108
|
+
self.assertIn("xn--fsqu00a.xn--0zwm56d", values_of(recs, "domain"))
|
|
109
|
+
|
|
110
|
+
def test_domain_filename_md_not_domain(self):
|
|
111
|
+
recs = yi.extract_iocs("README.md 是文档")
|
|
112
|
+
self.assertNotIn("readme.md", values_of(recs, "domain"))
|
|
113
|
+
|
|
114
|
+
def test_domain_filename_py_not_domain(self):
|
|
115
|
+
recs = yi.extract_iocs("test.py 是脚本")
|
|
116
|
+
self.assertNotIn("test.py", values_of(recs, "domain"))
|
|
117
|
+
|
|
118
|
+
def test_domain_filename_sh_not_domain(self):
|
|
119
|
+
recs = yi.extract_iocs("install.sh")
|
|
120
|
+
self.assertNotIn("install.sh", values_of(recs, "domain"))
|
|
121
|
+
|
|
122
|
+
def test_domain_unknown_tld_rejected(self):
|
|
123
|
+
recs = yi.extract_iocs("badexample.zzz")
|
|
124
|
+
self.assertNotIn("badexample.zzz", values_of(recs, "domain"))
|
|
125
|
+
|
|
126
|
+
def test_domain_numeric_tld_rejected(self):
|
|
127
|
+
recs = yi.extract_iocs("1.2.3.44")
|
|
128
|
+
self.assertNotIn("1.2.3.44", values_of(recs, "domain"))
|
|
129
|
+
|
|
130
|
+
def test_domain_subdomain_full_fqdn(self):
|
|
131
|
+
recs = yi.extract_iocs("cdn.evil.example.com")
|
|
132
|
+
self.assertIn("cdn.evil.example.com", values_of(recs, "domain"))
|
|
133
|
+
|
|
134
|
+
def test_domain_single_label_rejected(self):
|
|
135
|
+
recs = yi.extract_iocs("localhost 服务")
|
|
136
|
+
self.assertNotIn("localhost", values_of(recs, "domain"))
|
|
137
|
+
|
|
138
|
+
def test_domain_not_extracted_inside_email(self):
|
|
139
|
+
recs = yi.extract_iocs("admin@example.com")
|
|
140
|
+
self.assertNotIn("example.com", values_of(recs, "domain"))
|
|
141
|
+
self.assertIn("admin@example.com", values_of(recs, "email"))
|
|
142
|
+
|
|
143
|
+
def test_domain_hyphen_label(self):
|
|
144
|
+
recs = yi.extract_iocs("my-site.example.com")
|
|
145
|
+
self.assertIn("my-site.example.com", values_of(recs, "domain"))
|
|
146
|
+
|
|
147
|
+
def test_domain_cc_tld(self):
|
|
148
|
+
recs = yi.extract_iocs("shop.example.cn")
|
|
149
|
+
self.assertIn("shop.example.cn", values_of(recs, "domain"))
|
|
150
|
+
|
|
151
|
+
class TestURL(unittest.TestCase):
|
|
152
|
+
def test_url_basic(self):
|
|
153
|
+
recs = yi.extract_iocs("http://example.com/a")
|
|
154
|
+
self.assertIn("http://example.com/a", values_of(recs, "url"))
|
|
155
|
+
|
|
156
|
+
def test_url_https(self):
|
|
157
|
+
recs = yi.extract_iocs("https://example.com/a")
|
|
158
|
+
self.assertIn("https://example.com/a", values_of(recs, "url"))
|
|
159
|
+
|
|
160
|
+
def test_url_default_port_stripped(self):
|
|
161
|
+
recs = yi.extract_iocs("http://example.com:80/x")
|
|
162
|
+
self.assertIn("http://example.com/x", values_of(recs, "url"))
|
|
163
|
+
|
|
164
|
+
def test_url_nondefault_port_kept(self):
|
|
165
|
+
recs = yi.extract_iocs("http://example.com:8080/x")
|
|
166
|
+
self.assertIn("http://example.com:8080/x", values_of(recs, "url"))
|
|
167
|
+
|
|
168
|
+
def test_url_fragment_dropped(self):
|
|
169
|
+
recs = yi.extract_iocs("http://example.com/a#section")
|
|
170
|
+
self.assertIn("http://example.com/a", values_of(recs, "url"))
|
|
171
|
+
|
|
172
|
+
def test_url_query_kept(self):
|
|
173
|
+
recs = yi.extract_iocs("http://example.com/a?id=1&x=2")
|
|
174
|
+
self.assertIn("http://example.com/a?id=1&x=2", values_of(recs, "url"))
|
|
175
|
+
|
|
176
|
+
def test_url_case_normalized(self):
|
|
177
|
+
recs = yi.extract_iocs("HTTP://EXAMPLE.COM/A")
|
|
178
|
+
self.assertIn("http://example.com/A", values_of(recs, "url"))
|
|
179
|
+
|
|
180
|
+
def test_url_trailing_punct_stripped(self):
|
|
181
|
+
recs = yi.extract_iocs("(https://example.com/a).")
|
|
182
|
+
self.assertIn("https://example.com/a", values_of(recs, "url"))
|
|
183
|
+
|
|
184
|
+
def test_url_cjk_punct_stops(self):
|
|
185
|
+
recs = yi.extract_iocs("http://example.com/a,链接")
|
|
186
|
+
self.assertIn("http://example.com/a", values_of(recs, "url"))
|
|
187
|
+
|
|
188
|
+
def test_url_ip_host(self):
|
|
189
|
+
recs = yi.extract_iocs("http://1.2.3.4/x")
|
|
190
|
+
self.assertIn("http://1.2.3.4/x", values_of(recs, "url"))
|
|
191
|
+
self.assertIn("1.2.3.4", values_of(recs, "ipv4"))
|
|
192
|
+
|
|
193
|
+
def test_url_ipv6_host(self):
|
|
194
|
+
recs = yi.extract_iocs("http://[2001:db8::1]/x")
|
|
195
|
+
self.assertIn("http://[2001:db8::1]/x", values_of(recs, "url"))
|
|
196
|
+
|
|
197
|
+
def test_url_defanged_url(self):
|
|
198
|
+
recs = yi.extract_iocs("hxxp://bad[.]com/a")
|
|
199
|
+
self.assertIn("http://bad.com/a", values_of(recs, "url"))
|
|
200
|
+
|
|
201
|
+
def test_url_invalid_scheme_rejected(self):
|
|
202
|
+
recs = yi.extract_iocs("file:///etc/passwd")
|
|
203
|
+
self.assertNotIn("file:///etc/passwd", values_of(recs, "url"))
|
|
204
|
+
|
|
205
|
+
def test_url_userinfo_defanged(self):
|
|
206
|
+
recs = yi.extract_iocs("http://user:pass@example.com/a")
|
|
207
|
+
self.assertIn("http://user:pass@example.com/a", values_of(recs, "url"))
|
|
208
|
+
url = [r for r in recs if r["type"] == "url"][0]
|
|
209
|
+
self.assertIn("[@]", url["defanged"])
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class TestEmail(unittest.TestCase):
|
|
213
|
+
def test_email_basic(self):
|
|
214
|
+
recs = yi.extract_iocs("联系 admin@example.com")
|
|
215
|
+
self.assertIn("admin@example.com", values_of(recs, "email"))
|
|
216
|
+
|
|
217
|
+
def test_email_uppercase_normalized(self):
|
|
218
|
+
recs = yi.extract_iocs("Admin@Example.COM")
|
|
219
|
+
self.assertIn("admin@example.com", values_of(recs, "email"))
|
|
220
|
+
|
|
221
|
+
def test_email_dotted_user(self):
|
|
222
|
+
recs = yi.extract_iocs("user.name@example.com")
|
|
223
|
+
self.assertIn("user.name@example.com", values_of(recs, "email"))
|
|
224
|
+
|
|
225
|
+
def test_email_no_domain_dot_rejected(self):
|
|
226
|
+
recs = yi.extract_iocs("a@b 不是邮箱")
|
|
227
|
+
self.assertNotIn("a@b", values_of(recs, "email"))
|
|
228
|
+
|
|
229
|
+
def test_email_defanged(self):
|
|
230
|
+
recs = yi.extract_iocs("admin[@]example[.]com")
|
|
231
|
+
self.assertIn("admin@example.com", values_of(recs, "email"))
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class TestHash(unittest.TestCase):
|
|
235
|
+
MD5 = "44d88612fea8a8f36de82e1278abb02f"
|
|
236
|
+
SHA1 = "da39a3ee5e6b4b0d3255bfef95601890afd80709"
|
|
237
|
+
SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
|
238
|
+
SHA512 = ("cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce"
|
|
239
|
+
"47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e")
|
|
240
|
+
|
|
241
|
+
def test_hash_md5(self):
|
|
242
|
+
recs = yi.extract_iocs(self.MD5)
|
|
243
|
+
self.assertIn(self.MD5, values_of(recs, "hash"))
|
|
244
|
+
|
|
245
|
+
def test_hash_sha1(self):
|
|
246
|
+
recs = yi.extract_iocs(self.SHA1)
|
|
247
|
+
self.assertIn(self.SHA1, values_of(recs, "hash"))
|
|
248
|
+
|
|
249
|
+
def test_hash_sha256(self):
|
|
250
|
+
recs = yi.extract_iocs(self.SHA256)
|
|
251
|
+
self.assertIn(self.SHA256, values_of(recs, "hash"))
|
|
252
|
+
|
|
253
|
+
def test_hash_sha512(self):
|
|
254
|
+
recs = yi.extract_iocs(self.SHA512)
|
|
255
|
+
self.assertIn(self.SHA512, values_of(recs, "hash"))
|
|
256
|
+
|
|
257
|
+
def test_hash_uppercase_normalized(self):
|
|
258
|
+
recs = yi.extract_iocs(self.MD5.upper())
|
|
259
|
+
self.assertIn(self.MD5, values_of(recs, "hash"))
|
|
260
|
+
|
|
261
|
+
def test_hash_unknown_length_rejected(self):
|
|
262
|
+
recs = yi.extract_iocs("ab" * 24)
|
|
263
|
+
self.assertEqual(values_of(recs, "hash"), [])
|
|
264
|
+
|
|
265
|
+
def test_hash_uuid_not_matched(self):
|
|
266
|
+
recs = yi.extract_iocs("550e8400-e29b-41d4-a716-446655440000")
|
|
267
|
+
self.assertEqual(values_of(recs, "hash"), [])
|
|
268
|
+
|
|
269
|
+
def test_hash_label_md5(self):
|
|
270
|
+
recs = yi.extract_iocs("MD5: " + self.MD5)
|
|
271
|
+
self.assertIn(self.MD5, values_of(recs, "hash"))
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class TestCVE(unittest.TestCase):
|
|
275
|
+
def test_cve_basic(self):
|
|
276
|
+
recs = yi.extract_iocs("CVE-2024-1234")
|
|
277
|
+
self.assertIn("CVE-2024-1234", values_of(recs, "cve"))
|
|
278
|
+
|
|
279
|
+
def test_cve_lowercase_normalized(self):
|
|
280
|
+
recs = yi.extract_iocs("cve-2024-12345")
|
|
281
|
+
self.assertIn("CVE-2024-12345", values_of(recs, "cve"))
|
|
282
|
+
|
|
283
|
+
def test_cve_defanged_unchanged(self):
|
|
284
|
+
recs = yi.extract_iocs("CVE-2024-1234")
|
|
285
|
+
cve = [r for r in recs if r["type"] == "cve"][0]
|
|
286
|
+
self.assertEqual(cve["defanged"], "CVE-2024-1234")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class TestDefangRefang(unittest.TestCase):
|
|
290
|
+
def test_refang_dot_bracket(self):
|
|
291
|
+
self.assertEqual(yi.refang_text("bad[.]com"), "bad.com")
|
|
292
|
+
|
|
293
|
+
def test_refang_dot_paren(self):
|
|
294
|
+
self.assertEqual(yi.refang_text("bad(.)com"), "bad.com")
|
|
295
|
+
|
|
296
|
+
def test_refang_dot_word(self):
|
|
297
|
+
self.assertEqual(yi.refang_text("bad[dot]com"), "bad.com")
|
|
298
|
+
self.assertEqual(yi.refang_text("bad(DOT)com"), "bad.com")
|
|
299
|
+
|
|
300
|
+
def test_refang_colon(self):
|
|
301
|
+
self.assertEqual(yi.refang_text("1[:]2"), "1:2")
|
|
302
|
+
|
|
303
|
+
def test_refang_at(self):
|
|
304
|
+
self.assertEqual(yi.refang_text("user[@]example.com"), "user@example.com")
|
|
305
|
+
|
|
306
|
+
def test_refang_hxxp(self):
|
|
307
|
+
self.assertEqual(yi.refang_text("hxxp://a.com"), "http://a.com")
|
|
308
|
+
self.assertEqual(yi.refang_text("hXXps://a.com"), "https://a.com")
|
|
309
|
+
|
|
310
|
+
def test_refang_slash(self):
|
|
311
|
+
self.assertEqual(yi.refang_text("a[/]b"), "a/b")
|
|
312
|
+
|
|
313
|
+
def test_defang_value_ipv4(self):
|
|
314
|
+
self.assertEqual(yi.defang_value("1.2.3.4", "ipv4"), "1[.]2[.]3[.]4")
|
|
315
|
+
|
|
316
|
+
def test_defang_value_ipv6(self):
|
|
317
|
+
self.assertEqual(yi.defang_value("2001:db8::1", "ipv6"), "2001[:]db8[:][:]1")
|
|
318
|
+
|
|
319
|
+
def test_defang_value_domain(self):
|
|
320
|
+
self.assertEqual(yi.defang_value("example.com", "domain"), "example[.]com")
|
|
321
|
+
|
|
322
|
+
def test_defang_value_url(self):
|
|
323
|
+
self.assertEqual(yi.defang_value("http://example.com/a", "url"),
|
|
324
|
+
"hxxp://example[.]com/a")
|
|
325
|
+
|
|
326
|
+
def test_defang_value_email(self):
|
|
327
|
+
self.assertEqual(yi.defang_value("a@b.com", "email"), "a[@]b[.]com")
|
|
328
|
+
|
|
329
|
+
def test_roundtrip_extract_defang(self):
|
|
330
|
+
recs = yi.extract_iocs("hxxp://bad[.]com/a")
|
|
331
|
+
self.assertIn("http://bad.com/a", values_of(recs, "url"))
|
|
332
|
+
url = [r for r in recs if r["type"] == "url"][0]
|
|
333
|
+
self.assertEqual(url["defanged"], "hxxp://bad[.]com/a")
|
|
334
|
+
|
|
335
|
+
class TestExtract(unittest.TestCase):
|
|
336
|
+
def test_extract_dedup_count(self):
|
|
337
|
+
recs = yi.extract_iocs("203.0.113.5\n203.0.113.5")
|
|
338
|
+
v = [r for r in recs if r["type"] == "ipv4"]
|
|
339
|
+
self.assertEqual(len(v), 1)
|
|
340
|
+
self.assertEqual(v[0]["count"], 2)
|
|
341
|
+
|
|
342
|
+
def test_extract_first_line(self):
|
|
343
|
+
recs = yi.extract_iocs("第一行\n第二行 evil.example.com")
|
|
344
|
+
d = [r for r in recs if r["type"] == "domain"][0]
|
|
345
|
+
self.assertEqual(d["first_line"], 2)
|
|
346
|
+
|
|
347
|
+
def test_extract_min_count(self):
|
|
348
|
+
text = "203.0.113.5\n203.0.113.5\n1.1.1.1"
|
|
349
|
+
recs = yi.extract_iocs(text, min_count=2)
|
|
350
|
+
v = values_of(recs, "ipv4")
|
|
351
|
+
self.assertIn("203.0.113.5", v)
|
|
352
|
+
self.assertNotIn("1.1.1.1", v)
|
|
353
|
+
|
|
354
|
+
def test_extract_types_filter(self):
|
|
355
|
+
recs = yi.extract_iocs("203.0.113.5 evil.example.com", types=["domain"])
|
|
356
|
+
self.assertEqual(types_of(recs), ["domain"])
|
|
357
|
+
|
|
358
|
+
def test_extract_snippet_context(self):
|
|
359
|
+
recs = yi.extract_iocs("前文 203.0.113.5 后文")
|
|
360
|
+
v = [r for r in recs if r["type"] == "ipv4"][0]
|
|
361
|
+
self.assertIn("前文", v["snippet"])
|
|
362
|
+
|
|
363
|
+
def test_extract_no_ioc(self):
|
|
364
|
+
recs = yi.extract_iocs("没有任何指标的一行普通文字")
|
|
365
|
+
self.assertEqual(recs, [])
|
|
366
|
+
|
|
367
|
+
def test_extract_mixed_defanged_and_raw_dedup(self):
|
|
368
|
+
recs = yi.extract_iocs("hxxp://a[.]com\nhttp://a.com")
|
|
369
|
+
urls = values_of(recs, "url")
|
|
370
|
+
self.assertEqual(len(urls), 1)
|
|
371
|
+
self.assertEqual(urls[0], "http://a.com")
|
|
372
|
+
|
|
373
|
+
def test_extract_line_keepends(self):
|
|
374
|
+
text = "203.0.113.5\r\n1.1.1.1\r\n"
|
|
375
|
+
recs = yi.extract_iocs(text)
|
|
376
|
+
self.assertEqual(len(values_of(recs, "ipv4")), 2)
|
|
377
|
+
|
|
378
|
+
def test_extract_hex_in_text_not_hash(self):
|
|
379
|
+
recs = yi.extract_iocs("颜色是 #ff0000")
|
|
380
|
+
self.assertEqual(values_of(recs, "hash"), [])
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
class TestOutputs(unittest.TestCase):
|
|
384
|
+
def test_json_structure(self):
|
|
385
|
+
recs = yi.extract_iocs("203.0.113.5")
|
|
386
|
+
doc = json.loads(yi.build_json(recs, "2026-08-27T00:00:00+00:00", "test.txt"))
|
|
387
|
+
self.assertEqual(doc["tool"], "yotta-intel")
|
|
388
|
+
self.assertEqual(doc["summary"]["total"], 1)
|
|
389
|
+
self.assertEqual(doc["indicators"][0]["value"], "203.0.113.5")
|
|
390
|
+
|
|
391
|
+
def test_csv_columns(self):
|
|
392
|
+
recs = yi.extract_iocs("203.0.113.5")
|
|
393
|
+
text = yi.build_csv(recs)
|
|
394
|
+
self.assertTrue(text.startswith("type,value,defanged,count,first_line,snippet"))
|
|
395
|
+
self.assertIn("203.0.113.5", text)
|
|
396
|
+
|
|
397
|
+
def test_stix_bundle(self):
|
|
398
|
+
recs = yi.extract_iocs("203.0.113.5 evil.example.com")
|
|
399
|
+
bundle = yi.build_stix(recs, "2026-08-27T00:00:00+00:00")
|
|
400
|
+
self.assertEqual(bundle["type"], "bundle")
|
|
401
|
+
self.assertEqual(bundle["spec_version"], "2.1")
|
|
402
|
+
self.assertEqual(len(bundle["objects"]), 2)
|
|
403
|
+
self.assertTrue(all(o["type"] == "indicator" for o in bundle["objects"]))
|
|
404
|
+
|
|
405
|
+
def test_stix_pattern_ipv4(self):
|
|
406
|
+
self.assertEqual(yi.stix_pattern("ipv4", "1.2.3.4"),
|
|
407
|
+
"[ipv4-addr:value = '1.2.3.4']")
|
|
408
|
+
|
|
409
|
+
def test_stix_pattern_domain(self):
|
|
410
|
+
self.assertEqual(yi.stix_pattern("domain", "example.com"),
|
|
411
|
+
"[domain-name:value = 'example.com']")
|
|
412
|
+
|
|
413
|
+
def test_stix_pattern_hash_md5(self):
|
|
414
|
+
self.assertEqual(yi.stix_pattern("hash", "ab" * 16),
|
|
415
|
+
"[file:hashes.'MD5' = '%s']" % ("ab" * 16))
|
|
416
|
+
|
|
417
|
+
def test_stix_pattern_hash_sha256(self):
|
|
418
|
+
self.assertEqual(yi.stix_pattern("hash", "ab" * 32),
|
|
419
|
+
"[file:hashes.'SHA-256' = '%s']" % ("ab" * 32))
|
|
420
|
+
|
|
421
|
+
def test_stix_uuid_deterministic(self):
|
|
422
|
+
b1 = yi.build_stix(yi.extract_iocs("203.0.113.5"), "t")
|
|
423
|
+
b2 = yi.build_stix(yi.extract_iocs("203.0.113.5"), "t")
|
|
424
|
+
self.assertEqual(b1["objects"][0]["id"], b2["objects"][0]["id"])
|
|
425
|
+
|
|
426
|
+
def test_text_output(self):
|
|
427
|
+
recs = yi.extract_iocs("203.0.113.5")
|
|
428
|
+
out = yi.build_text(recs)
|
|
429
|
+
self.assertIn("203.0.113.5", out)
|
|
430
|
+
self.assertIn("defang", out)
|
|
431
|
+
|
|
432
|
+
def test_text_output_empty(self):
|
|
433
|
+
out = yi.build_text([])
|
|
434
|
+
self.assertIn("未发现", out)
|
|
435
|
+
|
|
436
|
+
def test_defang_stream(self):
|
|
437
|
+
out = yi.defang_text("访问 http://example.com/a")
|
|
438
|
+
self.assertIn("hxxp://example[.]com/a", out)
|
|
439
|
+
|
|
440
|
+
def test_refang_stream(self):
|
|
441
|
+
out = yi.refang_text("hxxp://example[.]com/a")
|
|
442
|
+
self.assertIn("http://example.com/a", out)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
class TestCLI(unittest.TestCase):
|
|
446
|
+
def test_cli_extract_stdin_exit1(self):
|
|
447
|
+
r = run_cli("extract", "--stdin", stdin="203.0.113.5")
|
|
448
|
+
self.assertEqual(r.returncode, 1)
|
|
449
|
+
self.assertIn("203.0.113.5", r.stdout)
|
|
450
|
+
|
|
451
|
+
def test_cli_extract_no_ioc_exit0(self):
|
|
452
|
+
r = run_cli("extract", "--stdin", stdin="没有指标")
|
|
453
|
+
self.assertEqual(r.returncode, 0)
|
|
454
|
+
|
|
455
|
+
def test_cli_json_output(self):
|
|
456
|
+
r = run_cli("extract", "--stdin", "--format", "json", stdin="1.2.3.4")
|
|
457
|
+
doc = json.loads(r.stdout)
|
|
458
|
+
self.assertEqual(doc["indicators"][0]["type"], "ipv4")
|
|
459
|
+
|
|
460
|
+
def test_cli_csv_output(self):
|
|
461
|
+
r = run_cli("extract", "--stdin", "--format", "csv", stdin="1.2.3.4")
|
|
462
|
+
self.assertTrue(r.stdout.startswith("type,value"))
|
|
463
|
+
|
|
464
|
+
def test_cli_stix_output(self):
|
|
465
|
+
r = run_cli("extract", "--stdin", "--format", "stix", stdin="1.2.3.4")
|
|
466
|
+
doc = json.loads(r.stdout)
|
|
467
|
+
self.assertEqual(doc["type"], "bundle")
|
|
468
|
+
|
|
469
|
+
def test_cli_output_file(self):
|
|
470
|
+
with tempfile.TemporaryDirectory() as td:
|
|
471
|
+
out = os.path.join(td, "out.json")
|
|
472
|
+
r = run_cli("extract", "--stdin", "--format", "json", "--output", out,
|
|
473
|
+
stdin="1.2.3.4")
|
|
474
|
+
self.assertEqual(r.returncode, 1)
|
|
475
|
+
doc = json.loads(io.open(out, encoding="utf-8").read())
|
|
476
|
+
self.assertEqual(doc["indicators"][0]["value"], "1.2.3.4")
|
|
477
|
+
|
|
478
|
+
def test_cli_defang(self):
|
|
479
|
+
r = run_cli("defang", "--stdin", stdin="http://a.com")
|
|
480
|
+
self.assertEqual(r.returncode, 0)
|
|
481
|
+
self.assertIn("hxxp://a[.]com", r.stdout)
|
|
482
|
+
|
|
483
|
+
def test_cli_refang(self):
|
|
484
|
+
r = run_cli("refang", "--stdin", stdin="hxxp://a[.]com")
|
|
485
|
+
self.assertEqual(r.returncode, 0)
|
|
486
|
+
self.assertIn("http://a.com", r.stdout)
|
|
487
|
+
|
|
488
|
+
def test_cli_version(self):
|
|
489
|
+
r = run_cli("--version")
|
|
490
|
+
self.assertEqual(r.returncode, 0)
|
|
491
|
+
self.assertIn("0.1.0", r.stdout)
|
|
492
|
+
|
|
493
|
+
def test_cli_unknown_type_exit4(self):
|
|
494
|
+
r = run_cli("extract", "--stdin", "--types", "bogus", stdin="1.2.3.4")
|
|
495
|
+
self.assertEqual(r.returncode, 4)
|
|
496
|
+
|
|
497
|
+
def test_cli_missing_input_exit4(self):
|
|
498
|
+
r = run_cli("extract")
|
|
499
|
+
self.assertEqual(r.returncode, 4)
|
|
500
|
+
|
|
501
|
+
def test_cli_bad_path_exit4(self):
|
|
502
|
+
r = run_cli("extract", "--path", os.path.join(tempfile.gettempdir(), "no-such-file-xyz"))
|
|
503
|
+
self.assertEqual(r.returncode, 4)
|
|
504
|
+
|
|
505
|
+
def test_cli_min_count(self):
|
|
506
|
+
r = run_cli("extract", "--stdin", "--min-count", "2", stdin="1.1.1.1\n1.1.1.1\n2.2.2.2")
|
|
507
|
+
self.assertIn("1.1.1.1", r.stdout)
|
|
508
|
+
self.assertNotIn("2.2.2.2", r.stdout)
|
|
509
|
+
|
|
510
|
+
def test_cli_path_file(self):
|
|
511
|
+
with tempfile.TemporaryDirectory() as td:
|
|
512
|
+
src = os.path.join(td, "intel.txt")
|
|
513
|
+
io.open(src, "w", encoding="utf-8").write("evil.example.com")
|
|
514
|
+
r = run_cli("extract", "--path", src)
|
|
515
|
+
self.assertEqual(r.returncode, 1)
|
|
516
|
+
self.assertIn("evil.example.com", r.stdout)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
if __name__ == "__main__":
|
|
520
|
+
unittest.main(verbosity=2)
|