@yottameta/yotta-chain 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.
@@ -0,0 +1,616 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """yotta-chain (yuan lian) - unit tests.
4
+
5
+ Run: python3 scripts/test_yotta_chain.py
6
+ """
7
+ import argparse
8
+ import json
9
+ import os
10
+ import sys
11
+ import tempfile
12
+ import unittest
13
+ from pathlib import Path
14
+
15
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
16
+ import yotta_chain as yc
17
+
18
+
19
+ def write_files(base, files):
20
+ for rel, content in files.items():
21
+ p = Path(base) / rel
22
+ p.parent.mkdir(parents=True, exist_ok=True)
23
+ p.write_text(content, encoding="utf-8")
24
+
25
+
26
+ def scan_dir(path, level="info", gate="info"):
27
+ args = argparse.Namespace(path=str(path), format="text", level=level, gate=gate, output=None)
28
+ findings = []
29
+ sbom = []
30
+ root = {}
31
+ eco = yc._collect(str(path), findings, sbom, root)
32
+ return eco, findings
33
+
34
+
35
+ def rules_of(findings):
36
+ return {f.rule for f in findings}
37
+
38
+
39
+ def has(findings, rule):
40
+ return any(f.rule == rule for f in findings)
41
+
42
+
43
+ class TestSemver(unittest.TestCase):
44
+ def test_caret(self):
45
+ self.assertTrue(yc.semver_satisfies("1.2.3", "^1.2.0"))
46
+ self.assertTrue(yc.semver_satisfies("1.9.9", "^1.2.0"))
47
+ self.assertFalse(yc.semver_satisfies("2.0.0", "^1.2.0"))
48
+ self.assertTrue(yc.semver_satisfies("0.2.5", "^0.2.3"))
49
+ self.assertFalse(yc.semver_satisfies("0.3.0", "^0.2.3"))
50
+
51
+ def test_tilde(self):
52
+ self.assertTrue(yc.semver_satisfies("1.2.9", "~1.2.0"))
53
+ self.assertFalse(yc.semver_satisfies("1.3.0", "~1.2.0"))
54
+ self.assertTrue(yc.semver_satisfies("1.2.0", "~1.2"))
55
+
56
+ def test_comparators(self):
57
+ self.assertTrue(yc.semver_satisfies("1.5.0", ">=1.2.0 <2.0.0"))
58
+ self.assertFalse(yc.semver_satisfies("2.0.0", ">=1.2.0 <2.0.0"))
59
+ self.assertTrue(yc.semver_satisfies("1.2.3", "<=1.2.3"))
60
+ self.assertFalse(yc.semver_satisfies("1.2.4", "<=1.2.3"))
61
+
62
+ def test_xrange(self):
63
+ self.assertTrue(yc.semver_satisfies("1.2.9", "1.2.x"))
64
+ self.assertFalse(yc.semver_satisfies("1.3.0", "1.2.x"))
65
+ self.assertTrue(yc.semver_satisfies("1.9.9", "1.x"))
66
+ self.assertTrue(yc.semver_satisfies("1.2.3", "*"))
67
+
68
+ def test_exact(self):
69
+ self.assertTrue(yc.semver_satisfies("1.2.3", "1.2.3"))
70
+ self.assertFalse(yc.semver_satisfies("1.2.4", "1.2.3"))
71
+
72
+ def test_hyphen(self):
73
+ self.assertTrue(yc.semver_satisfies("1.9.0", "1.2.3 - 2.0.0"))
74
+ self.assertTrue(yc.semver_satisfies("2.0.0", "1.2.3 - 2.0.0"))
75
+ self.assertFalse(yc.semver_satisfies("2.1.0", "1.2.3 - 2.0.0"))
76
+
77
+ def test_or(self):
78
+ self.assertTrue(yc.semver_satisfies("1.5.0", "^1.0.0 || ^2.0.0"))
79
+ self.assertTrue(yc.semver_satisfies("2.5.0", "^1.0.0 || ^2.0.0"))
80
+ self.assertFalse(yc.semver_satisfies("3.0.0", "^1.0.0 || ^2.0.0"))
81
+
82
+ def test_prerelease_rule(self):
83
+ self.assertFalse(yc.semver_satisfies("1.5.0-beta.1", "^1.0.0"))
84
+ self.assertTrue(yc.semver_satisfies("1.5.0", "^1.0.0"))
85
+ self.assertTrue(yc.semver_satisfies("1.5.0-beta.1", "^1.5.0-beta.0"))
86
+
87
+
88
+ class TestPep440(unittest.TestCase):
89
+ def test_basic(self):
90
+ self.assertTrue(yc.pep440_satisfies("2.31.0", ">=2.28"))
91
+ self.assertFalse(yc.pep440_satisfies("2.27.0", ">=2.28"))
92
+ self.assertTrue(yc.pep440_satisfies("2.31.0", "==2.31.0"))
93
+ self.assertFalse(yc.pep440_satisfies("2.31.1", "==2.31.0"))
94
+ self.assertTrue(yc.pep440_satisfies("2.31.0", ">=2.28,<3.0"))
95
+ self.assertFalse(yc.pep440_satisfies("3.1.0", ">=2.28,<3.0"))
96
+
97
+ def test_compatible(self):
98
+ self.assertTrue(yc.pep440_satisfies("1.2.9", "~=1.2"))
99
+ self.assertFalse(yc.pep440_satisfies("1.3.0", "~=1.2"))
100
+ self.assertTrue(yc.pep440_satisfies("1.4.5", "~=1.4.5"))
101
+ self.assertFalse(yc.pep440_satisfies("1.5.0", "~=1.4.5"))
102
+
103
+ def test_wildcard(self):
104
+ self.assertTrue(yc.pep440_satisfies("2.31.4", "==2.31.*"))
105
+ self.assertFalse(yc.pep440_satisfies("2.32.0", "==2.31.*"))
106
+
107
+ def test_pre_release_order(self):
108
+ self.assertTrue(yc.pep440_satisfies("1.0.0b1", "<1.0.0"))
109
+ self.assertFalse(yc.pep440_satisfies("1.0.0", "<1.0.0"))
110
+ self.assertTrue(yc.pep440_satisfies("1.0.0", ">=1.0.0"))
111
+
112
+
113
+ class TestToml(unittest.TestCase):
114
+ def test_tables_and_values(self):
115
+ doc = '''
116
+ [project]
117
+ name = "demo"
118
+ version = "0.1.0"
119
+ dependencies = [
120
+ "requests>=2.28",
121
+ 'flask<3.0',
122
+ ]
123
+ [tool.poetry.dependencies]
124
+ python = "^3.8"
125
+ requests = { version = ">=2.28", extras = ["socks"] }
126
+ '''
127
+ d = yc.parse_toml(doc)
128
+ self.assertEqual(d["project"]["name"], "demo")
129
+ self.assertEqual(d["project"]["dependencies"], ["requests>=2.28", "flask<3.0"])
130
+ self.assertEqual(d["tool"]["poetry"]["dependencies"]["python"], "^3.8")
131
+ self.assertEqual(d["tool"]["poetry"]["dependencies"]["requests"]["version"], ">=2.28")
132
+
133
+ def test_array_of_tables_with_nested(self):
134
+ doc = '''
135
+ [[package]]
136
+ name = "requests"
137
+ version = "2.31.0"
138
+ optional = false
139
+
140
+ [package.dependencies]
141
+ urllib3 = ">=1.21.1,<3"
142
+
143
+ [[package]]
144
+ name = "urllib3"
145
+ version = "2.0.4"
146
+
147
+ [[tool.poetry.source]]
148
+ name = "private"
149
+ url = "https://pypi.example.com/simple"
150
+ secondary = true
151
+ '''
152
+ d = yc.parse_toml(doc)
153
+ self.assertEqual(len(d["package"]), 2)
154
+ self.assertEqual(d["package"][0]["name"], "requests")
155
+ self.assertEqual(d["package"][0]["dependencies"]["urllib3"], ">=1.21.1,<3")
156
+ self.assertEqual(d["package"][1]["name"], "urllib3")
157
+ self.assertEqual(d["tool"]["poetry"]["source"][0]["url"], "https://pypi.example.com/simple")
158
+ self.assertTrue(d["tool"]["poetry"]["source"][0]["secondary"])
159
+
160
+ def test_triple_quoted_and_comments(self):
161
+ doc = '''
162
+ [project]
163
+ description = """A long
164
+ description here."""
165
+ version = "1.0.0" # inline comment
166
+ '''
167
+ d = yc.parse_toml(doc)
168
+ self.assertEqual(d["project"]["description"], "A long\ndescription here.")
169
+ self.assertEqual(d["project"]["version"], "1.0.0")
170
+
171
+ def test_dotted_key_and_inline_table(self):
172
+ doc = 'a.b.c = 1\narr = [ {x = 1}, {x = 2} ]\n'
173
+ d = yc.parse_toml(doc)
174
+ self.assertEqual(d["a"]["b"]["c"], 1)
175
+ self.assertEqual(d["arr"][0]["x"], 1)
176
+ self.assertEqual(d["arr"][1]["x"], 2)
177
+
178
+
179
+ class TestRequirements(unittest.TestCase):
180
+ def test_basic_pins(self):
181
+ out = yc.parse_requirements("requests==2.31.0\nflask>=2.0\nfoo[extra]>=1.0; python_version < '3.9'\n")
182
+ self.assertEqual(out["packages"]["requests"]["spec"], "==2.31.0")
183
+ self.assertEqual(out["packages"]["flask"]["spec"], ">=2.0")
184
+ self.assertEqual(out["packages"]["foo"]["spec"], ">=1.0")
185
+
186
+ def test_index_flags(self):
187
+ out = yc.parse_requirements(
188
+ "--index-url https://pypi.example.com/simple\n"
189
+ "--extra-index-url https://pypi.org/simple\n"
190
+ "requests==2.31.0\n")
191
+ self.assertEqual(out["index"], "https://pypi.example.com/simple")
192
+ self.assertEqual(out["extra"], ["https://pypi.org/simple"])
193
+
194
+ def test_hash_strip(self):
195
+ out = yc.parse_requirements("requests==2.31.0 --hash=sha256:abc\n")
196
+ self.assertEqual(out["packages"]["requests"]["spec"], "==2.31.0")
197
+ self.assertTrue(out["packages"]["requests"]["has_hash"])
198
+
199
+
200
+ class TestTyposquat(unittest.TestCase):
201
+ def test_hit(self):
202
+ self.assertIsNotNone(yc.find_typosquat("lodassh", yc.POPULAR_NPM))
203
+ self.assertIsNotNone(yc.find_typosquat("requets", yc.POPULAR_PYPI))
204
+
205
+ def test_clean(self):
206
+ self.assertIsNone(yc.find_typosquat("lodash", yc.POPULAR_NPM))
207
+ self.assertIsNone(yc.find_typosquat("my-company-tool", yc.POPULAR_NPM))
208
+
209
+
210
+ NPM_LOCK_V3 = {
211
+ "name": "demo",
212
+ "version": "1.0.0",
213
+ "lockfileVersion": 3,
214
+ "requires": True,
215
+ "packages": {
216
+ "": {"name": "demo", "version": "1.0.0",
217
+ "dependencies": {"lodash": "^4.17.21", "express": "^4.18.0"}},
218
+ "node_modules/lodash": {"version": "4.17.21",
219
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
220
+ "integrity": "sha512-aaa"},
221
+ "node_modules/express": {"version": "4.18.2",
222
+ "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz",
223
+ "integrity": "sha512-bbb",
224
+ "dependencies": {"accepts": "^1.3.8"}},
225
+ "node_modules/accepts": {"version": "1.3.8",
226
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
227
+ "integrity": "sha512-ccc"},
228
+ },
229
+ }
230
+
231
+
232
+ class TestNpmScan(unittest.TestCase):
233
+ def test_clean_project(self):
234
+ with tempfile.TemporaryDirectory() as td:
235
+ write_files(td, {
236
+ "package.json": json.dumps({
237
+ "name": "demo", "version": "1.0.0",
238
+ "dependencies": {"lodash": "^4.17.21", "express": "^4.18.0"}}),
239
+ "package-lock.json": json.dumps(NPM_LOCK_V3),
240
+ })
241
+ eco, findings = scan_dir(td)
242
+ self.assertEqual(eco, ["npm"])
243
+ self.assertEqual(findings, [])
244
+
245
+ def test_missing_lockfile(self):
246
+ with tempfile.TemporaryDirectory() as td:
247
+ write_files(td, {"package.json": json.dumps(
248
+ {"name": "demo", "dependencies": {"lodash": "^4.17.21"}})})
249
+ eco, findings = scan_dir(td)
250
+ self.assertTrue(has(findings, "missing_lockfile"))
251
+
252
+ def test_missing_entry(self):
253
+ with tempfile.TemporaryDirectory() as td:
254
+ write_files(td, {
255
+ "package.json": json.dumps(
256
+ {"name": "demo", "dependencies": {"ghostpkg": "^1.0.0"}}),
257
+ "package-lock.json": json.dumps(NPM_LOCK_V3),
258
+ })
259
+ _, findings = scan_dir(td)
260
+ self.assertTrue(has(findings, "lockfile_missing_entry"))
261
+
262
+ def test_range_unsatisfied(self):
263
+ with tempfile.TemporaryDirectory() as td:
264
+ write_files(td, {
265
+ "package.json": json.dumps(
266
+ {"name": "demo", "dependencies": {"lodash": "^5.0.0"}}),
267
+ "package-lock.json": json.dumps(NPM_LOCK_V3),
268
+ })
269
+ _, findings = scan_dir(td)
270
+ self.assertTrue(has(findings, "lockfile_range_unsatisfied"))
271
+
272
+ def test_dangling_ref(self):
273
+ lock = json.loads(json.dumps(NPM_LOCK_V3))
274
+ lock["packages"]["node_modules/express"]["dependencies"]["ghost"] = "^1.0.0"
275
+ with tempfile.TemporaryDirectory() as td:
276
+ write_files(td, {
277
+ "package.json": json.dumps(
278
+ {"name": "demo", "dependencies": {"express": "^4.18.0"}}),
279
+ "package-lock.json": json.dumps(lock),
280
+ })
281
+ _, findings = scan_dir(td)
282
+ self.assertTrue(has(findings, "lockfile_dangling_ref"))
283
+
284
+ def test_missing_integrity(self):
285
+ lock = json.loads(json.dumps(NPM_LOCK_V3))
286
+ del lock["packages"]["node_modules/accepts"]["integrity"]
287
+ with tempfile.TemporaryDirectory() as td:
288
+ write_files(td, {
289
+ "package.json": json.dumps(
290
+ {"name": "demo", "dependencies": {"express": "^4.18.0"}}),
291
+ "package-lock.json": json.dumps(lock),
292
+ })
293
+ _, findings = scan_dir(td)
294
+ self.assertTrue(has(findings, "lockfile_integrity_missing"))
295
+
296
+ def test_scope_registry_confusion(self):
297
+ lock = json.loads(json.dumps(NPM_LOCK_V3))
298
+ lock["packages"]["node_modules/@corp/secret"] = {
299
+ "version": "1.0.0",
300
+ "resolved": "https://registry.npmjs.org/@corp/secret/-/secret-1.0.0.tgz",
301
+ "integrity": "sha512-ddd",
302
+ }
303
+ lock["packages"][""]["dependencies"]["@corp/secret"] = "^1.0.0"
304
+ with tempfile.TemporaryDirectory() as td:
305
+ write_files(td, {
306
+ "package.json": json.dumps(
307
+ {"name": "demo", "dependencies": {"@corp/secret": "^1.0.0"}}),
308
+ "package-lock.json": json.dumps(lock),
309
+ ".npmrc": "@corp:registry=https://npm.corp.example.com/\n",
310
+ })
311
+ _, findings = scan_dir(td)
312
+ self.assertTrue(has(findings, "confusion_scope_registry"))
313
+
314
+ def test_suspicious_resolved_url(self):
315
+ lock = json.loads(json.dumps(NPM_LOCK_V3))
316
+ lock["packages"]["node_modules/lodash"]["resolved"] = "http://registry.example.com/lodash.tgz"
317
+ with tempfile.TemporaryDirectory() as td:
318
+ write_files(td, {
319
+ "package.json": json.dumps(
320
+ {"name": "demo", "dependencies": {"lodash": "^4.17.21"}}),
321
+ "package-lock.json": json.dumps(lock),
322
+ })
323
+ _, findings = scan_dir(td)
324
+ self.assertTrue(has(findings, "confusion_suspicious_registry"))
325
+
326
+ def test_mixed_registry(self):
327
+ lock = json.loads(json.dumps(NPM_LOCK_V3))
328
+ lock["packages"]["node_modules/express/node_modules/lodash"] = {
329
+ "version": "4.17.21",
330
+ "resolved": "https://mirror.corp.example.com/lodash.tgz",
331
+ "integrity": "sha512-eee",
332
+ }
333
+ with tempfile.TemporaryDirectory() as td:
334
+ write_files(td, {
335
+ "package.json": json.dumps(
336
+ {"name": "demo", "dependencies": {"lodash": "^4.17.21", "express": "^4.18.0"}}),
337
+ "package-lock.json": json.dumps(lock),
338
+ })
339
+ _, findings = scan_dir(td)
340
+ self.assertTrue(has(findings, "confusion_mixed_registry"))
341
+
342
+ def test_unpinned_and_typosquat(self):
343
+ with tempfile.TemporaryDirectory() as td:
344
+ write_files(td, {
345
+ "package.json": json.dumps(
346
+ {"name": "demo", "dependencies": {"lodassh": "*", "express": "latest"}}),
347
+ "package-lock.json": json.dumps(NPM_LOCK_V3),
348
+ })
349
+ _, findings = scan_dir(td)
350
+ self.assertTrue(has(findings, "unpinned"))
351
+ self.assertTrue(has(findings, "typosquat"))
352
+
353
+
354
+ class TestPythonScan(unittest.TestCase):
355
+ def test_requirements_unpinned_and_typosquat(self):
356
+ with tempfile.TemporaryDirectory() as td:
357
+ write_files(td, {"requirements.txt": "requests\nrequets==1.0.0\n"})
358
+ eco, findings = scan_dir(td)
359
+ self.assertIn("python", eco)
360
+ self.assertTrue(has(findings, "unpinned"))
361
+ self.assertTrue(has(findings, "typosquat"))
362
+
363
+ def test_requirements_index_confusion(self):
364
+ with tempfile.TemporaryDirectory() as td:
365
+ write_files(td, {"requirements.txt":
366
+ "--index-url https://pypi.example.com/simple\n"
367
+ "--extra-index-url https://pypi.org/simple\nrequests==2.31.0\n"})
368
+ _, findings = scan_dir(td)
369
+ self.assertTrue(has(findings, "confusion_extra_index"))
370
+
371
+ def test_pyproject_missing_poetry_lock(self):
372
+ with tempfile.TemporaryDirectory() as td:
373
+ write_files(td, {"pyproject.toml":
374
+ '[tool.poetry.dependencies]\npython = "^3.8"\nrequests = ">=2.28"\n'})
375
+ _, findings = scan_dir(td)
376
+ self.assertTrue(has(findings, "missing_lockfile"))
377
+
378
+ def test_poetry_lock_consistent(self):
379
+ with tempfile.TemporaryDirectory() as td:
380
+ write_files(td, {
381
+ "pyproject.toml": '[tool.poetry.dependencies]\npython = "^3.8"\nrequests = ">=2.28"\n',
382
+ "poetry.lock": '''
383
+ [[package]]
384
+ name = "requests"
385
+ version = "2.31.0"
386
+ files = [
387
+ {file = "requests-2.31.0.tar.gz", hash = "sha256:aaa"},
388
+ ]
389
+ [package.dependencies]
390
+ urllib3 = ">=1.21.1,<3"
391
+
392
+ [[package]]
393
+ name = "urllib3"
394
+ version = "2.0.4"
395
+ files = [
396
+ {file = "urllib3-2.0.4.tar.gz", hash = "sha256:bbb"},
397
+ ]
398
+ ''',
399
+ })
400
+ eco, findings = scan_dir(td)
401
+ self.assertIn("python", eco)
402
+ self.assertFalse(has(findings, "lockfile_missing_entry"))
403
+ self.assertFalse(has(findings, "lockfile_dangling_ref"))
404
+
405
+ def test_poetry_lock_missing_entry_and_dangling(self):
406
+ with tempfile.TemporaryDirectory() as td:
407
+ write_files(td, {
408
+ "pyproject.toml": '[tool.poetry.dependencies]\npython = "^3.8"\nflask = ">=2.0"\n',
409
+ "poetry.lock": '''
410
+ [[package]]
411
+ name = "requests"
412
+ version = "2.31.0"
413
+ [package.dependencies]
414
+ ghost = "*"
415
+ ''',
416
+ })
417
+ _, findings = scan_dir(td)
418
+ self.assertTrue(has(findings, "lockfile_missing_entry"))
419
+ self.assertTrue(has(findings, "lockfile_dangling_ref"))
420
+
421
+ def test_pipfile_lock_consistent_and_missing(self):
422
+ with tempfile.TemporaryDirectory() as td:
423
+ write_files(td, {
424
+ "Pipfile": "[[source]]\nname = \"pypi\"\nurl = \"https://pypi.org/simple\"\n[packages]\nrequests = \">=2.28\"\n",
425
+ "Pipfile.lock": json.dumps({
426
+ "_meta": {"hash": {"sha256": "x"}, "pipfile-spec": 6},
427
+ "default": {"requests": {"version": "==2.31.0", "hashes": ["sha256:abc"]}},
428
+ "develop": {},
429
+ }),
430
+ })
431
+ _, findings = scan_dir(td)
432
+ self.assertFalse(has(findings, "lockfile_missing_entry"))
433
+ self.assertFalse(has(findings, "lockfile_integrity_missing"))
434
+
435
+ def test_pipfile_missing_lock(self):
436
+ with tempfile.TemporaryDirectory() as td:
437
+ write_files(td, {"Pipfile": "[packages]\nrequests = \">=2.28\"\n"})
438
+ _, findings = scan_dir(td)
439
+ self.assertTrue(has(findings, "missing_lockfile"))
440
+
441
+ def test_pipfile_mixed_sources(self):
442
+ with tempfile.TemporaryDirectory() as td:
443
+ write_files(td, {"Pipfile":
444
+ '[[source]]\nname = "private"\nurl = "https://pypi.example.com/simple"\n'
445
+ '[[source]]\nname = "pypi"\nurl = "https://pypi.org/simple"\n'
446
+ '[packages]\nrequests = ">=2.28"\n'})
447
+ _, findings = scan_dir(td)
448
+ self.assertTrue(has(findings, "confusion_extra_index"))
449
+
450
+
451
+ class TestMavenScan(unittest.TestCase):
452
+ def test_unpinned_snapshot_suspicious_repo(self):
453
+ with tempfile.TemporaryDirectory() as td:
454
+ write_files(td, {"pom.xml": """<?xml version="1.0" encoding="UTF-8"?>
455
+ <project xmlns="http://maven.apache.org/POM/4.0.0">
456
+ <modelVersion>4.0.0</modelVersion>
457
+ <groupId>com.demo</groupId><artifactId>app</artifactId><version>1.0.0</version>
458
+ <dependencies>
459
+ <dependency><groupId>org.foo</groupId><artifactId>floating</artifactId></dependency>
460
+ <dependency><groupId>org.foo</groupId><artifactId>snap</artifactId><version>2.0.0-SNAPSHOT</version></dependency>
461
+ <dependency><groupId>com.google.guava</groupId><artifactId>guava</artifactId><version>32.1.0-jre</version></dependency>
462
+ </dependencies>
463
+ <repositories>
464
+ <repository><id>insecure</id><url>http://repo.example.com/maven2</url></repository>
465
+ </repositories>
466
+ </project>"""})
467
+ eco, findings = scan_dir(td)
468
+ self.assertIn("maven", eco)
469
+ self.assertTrue(has(findings, "unpinned"))
470
+ self.assertTrue(has(findings, "snapshot"))
471
+ self.assertTrue(has(findings, "confusion_suspicious_registry"))
472
+
473
+
474
+ class TestSbom(unittest.TestCase):
475
+ def _pkgs(self):
476
+ return [
477
+ {"ecosystem": "npm", "name": "@corp/secret", "version": "1.0.0",
478
+ "resolved": "https://registry.npmjs.org/@corp/secret.tgz", "integrity": "sha512-abc",
479
+ "scope": "required", "direct": True, "deps": []},
480
+ {"ecosystem": "npm", "name": "lodash", "version": "4.17.21",
481
+ "resolved": "https://registry.npmjs.org/lodash.tgz", "integrity": "sha512-def",
482
+ "scope": "required", "direct": True, "deps": []},
483
+ {"ecosystem": "python", "name": "requests", "version": "2.31.0",
484
+ "resolved": "", "integrity": "sha256:xxx",
485
+ "scope": "optional", "direct": False, "deps": []},
486
+ ]
487
+
488
+ def test_cyclonedx_structure(self):
489
+ bom = yc.build_sbom(self._pkgs(), include_dev=True,
490
+ root_component={"ecosystem": "npm", "name": "demo", "version": "1.0.0"})
491
+ self.assertEqual(bom["bomFormat"], "CycloneDX")
492
+ self.assertEqual(bom["specVersion"], "1.5")
493
+ self.assertEqual(bom["metadata"]["component"]["name"], "demo")
494
+ names = [c["name"] for c in bom["components"]]
495
+ self.assertIn("@corp/secret", names)
496
+ self.assertIn("requests", names)
497
+ scoped = [c for c in bom["components"] if c["name"] == "@corp/secret"][0]
498
+ self.assertTrue(scoped["purl"].startswith("pkg:npm/%40corp/secret@1.0.0"))
499
+ root_dep = [d for d in bom["dependencies"] if d["ref"].startswith("pkg:npm/demo@")][0]
500
+ self.assertEqual(len(root_dep["dependsOn"]), 2)
501
+
502
+ def test_exclude_dev(self):
503
+ bom = yc.build_sbom(self._pkgs(), include_dev=False)
504
+ names = {c["name"] for c in bom["components"]}
505
+ self.assertNotIn("requests", names)
506
+
507
+
508
+ class TestCli(unittest.TestCase):
509
+ def _run(self, td, cmd="scan"):
510
+ if cmd == "scan":
511
+ args = argparse.Namespace(path=td, format="json", level="info", gate="info", output=None)
512
+ return yc.cmd_scan(args)
513
+ args = argparse.Namespace(path=td, format="cyclonedx", exclude_dev=False, output=None)
514
+ return yc.cmd_sbom(args)
515
+
516
+ def test_scan_clean_exit0(self):
517
+ with tempfile.TemporaryDirectory() as td:
518
+ write_files(td, {
519
+ "package.json": json.dumps({"name": "demo", "dependencies": {"lodash": "^4.17.21"}}),
520
+ "package-lock.json": json.dumps(NPM_LOCK_V3),
521
+ })
522
+ self.assertEqual(self._run(td), 0)
523
+
524
+ def test_scan_findings_exit1(self):
525
+ with tempfile.TemporaryDirectory() as td:
526
+ write_files(td, {"requirements.txt": "requests\n"})
527
+ self.assertEqual(self._run(td), 1)
528
+
529
+ def test_scan_no_project_exit4(self):
530
+ with tempfile.TemporaryDirectory() as td:
531
+ write_files(td, {"notes.txt": "nothing here"})
532
+ self.assertEqual(self._run(td), 4)
533
+
534
+ def test_sbom_exit0_and_json(self):
535
+ import io
536
+ from contextlib import redirect_stdout
537
+ with tempfile.TemporaryDirectory() as td:
538
+ write_files(td, {
539
+ "package.json": json.dumps({"name": "demo", "dependencies": {"lodash": "^4.17.21"}}),
540
+ "package-lock.json": json.dumps(NPM_LOCK_V3),
541
+ })
542
+ buf = io.StringIO()
543
+ with redirect_stdout(buf):
544
+ code = self._run(td, cmd="sbom")
545
+ self.assertEqual(code, 0)
546
+ data = json.loads(buf.getvalue())
547
+ self.assertEqual(data["bomFormat"], "CycloneDX")
548
+
549
+ def test_version(self):
550
+ self.assertEqual(yc.VERSION, "0.1.0")
551
+
552
+
553
+ class TestNpmLockV1(unittest.TestCase):
554
+ def test_v1_parse_and_clean(self):
555
+ lock_v1 = {
556
+ "name": "demo",
557
+ "version": "1.0.0",
558
+ "lockfileVersion": 1,
559
+ "dependencies": {
560
+ "lodash": {"version": "4.17.21",
561
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
562
+ "integrity": "sha512-aaa"},
563
+ "@corp/secret": {"version": "1.0.0",
564
+ "resolved": "https://registry.npmjs.org/@corp/secret.tgz",
565
+ "integrity": "sha512-bbb",
566
+ "requires": {"lodash": "^4.0.0"}},
567
+ },
568
+ }
569
+ parsed = yc.parse_package_lock(json.dumps(lock_v1))
570
+ self.assertEqual(parsed["lockfileVersion"], 1)
571
+ self.assertIn("lodash", parsed["packages"])
572
+ self.assertIn("@corp/secret", parsed["packages"])
573
+ with tempfile.TemporaryDirectory() as td:
574
+ write_files(td, {
575
+ "package.json": json.dumps({
576
+ "name": "demo", "version": "1.0.0",
577
+ "dependencies": {"lodash": "^4.17.21", "@corp/secret": "^1.0.0"}}),
578
+ "package-lock.json": json.dumps(lock_v1),
579
+ })
580
+ _, findings = scan_dir(td)
581
+ self.assertEqual(findings, [])
582
+
583
+ def test_v1_dangling_requires(self):
584
+ lock_v1 = {
585
+ "name": "demo", "version": "1.0.0", "lockfileVersion": 1,
586
+ "dependencies": {
587
+ "pkg-a": {"version": "1.0.0",
588
+ "resolved": "https://registry.npmjs.org/pkg-a.tgz",
589
+ "requires": {"ghost": "^1.0.0"}},
590
+ },
591
+ }
592
+ with tempfile.TemporaryDirectory() as td:
593
+ write_files(td, {
594
+ "package.json": json.dumps({"name": "demo", "dependencies": {"pkg-a": "^1.0.0"}}),
595
+ "package-lock.json": json.dumps(lock_v1),
596
+ })
597
+ _, findings = scan_dir(td)
598
+ self.assertTrue(has(findings, "lockfile_dangling_ref"))
599
+
600
+
601
+ class TestPep440Edge(unittest.TestCase):
602
+ def test_not_equal(self):
603
+ self.assertFalse(yc.pep440_satisfies("2.31.0", "!=2.31.0"))
604
+ self.assertTrue(yc.pep440_satisfies("2.30.0", "!=2.31.0"))
605
+
606
+ def test_dev_order(self):
607
+ self.assertTrue(yc.pep440_satisfies("1.0.0.dev1", "<1.0.0"))
608
+ self.assertTrue(yc.pep440_satisfies("1.0.0rc1", "<1.0.0"))
609
+ self.assertFalse(yc.pep440_satisfies("1.0.0", "<1.0.0"))
610
+
611
+ def test_post(self):
612
+ self.assertTrue(yc.pep440_satisfies("1.0.0.post1", ">=1.0.0"))
613
+
614
+
615
+ if __name__ == "__main__":
616
+ unittest.main(verbosity=2)