samarkand 0.1.0__tar.gz
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.
- samarkand-0.1.0/.gitignore +26 -0
- samarkand-0.1.0/LICENSE +96 -0
- samarkand-0.1.0/PKG-INFO +188 -0
- samarkand-0.1.0/README.md +166 -0
- samarkand-0.1.0/docs/DIFFERENCES.md +112 -0
- samarkand-0.1.0/pyproject.toml +48 -0
- samarkand-0.1.0/src/samarkand/__init__.py +5 -0
- samarkand-0.1.0/src/samarkand/_http.py +78 -0
- samarkand-0.1.0/src/samarkand/licence.py +350 -0
- samarkand-0.1.0/src/samarkand/misp.py +581 -0
- samarkand-0.1.0/src/samarkand/navigator.py +167 -0
- samarkand-0.1.0/src/samarkand/py.typed +0 -0
- samarkand-0.1.0/src/samarkand/stix.py +876 -0
- samarkand-0.1.0/src/samarkand/taxii.py +127 -0
- samarkand-0.1.0/src/samarkand/webhooks.py +375 -0
- samarkand-0.1.0/tests/adapters.py +263 -0
- samarkand-0.1.0/tests/conftest.py +28 -0
- samarkand-0.1.0/tests/test_licence.py +248 -0
- samarkand-0.1.0/tests/test_misp.py +326 -0
- samarkand-0.1.0/tests/test_navigator.py +116 -0
- samarkand-0.1.0/tests/test_stix.py +350 -0
- samarkand-0.1.0/tests/test_taxii.py +157 -0
- samarkand-0.1.0/tests/test_vectors.py +34 -0
- samarkand-0.1.0/tests/test_webhooks.py +275 -0
- samarkand-0.1.0/tests/vectors/licence/THROWAWAY-TEST-SEED.key +6 -0
- samarkand-0.1.0/tests/vectors/licence/vectors.json +264 -0
- samarkand-0.1.0/tests/vectors/make_vectors.py +267 -0
- samarkand-0.1.0/tests/vectors/misp/edges.json +97 -0
- samarkand-0.1.0/tests/vectors/misp/expected-shikari.json +138 -0
- samarkand-0.1.0/tests/vectors/misp/ostraka-event.json +166 -0
- samarkand-0.1.0/tests/vectors/misp/shikari-event.json +48 -0
- samarkand-0.1.0/tests/vectors/misp/shikari-run.json +147 -0
- samarkand-0.1.0/tests/vectors/navigator/attack-sample.json +108 -0
- samarkand-0.1.0/tests/vectors/navigator/ostraka-layer.json +209 -0
- samarkand-0.1.0/tests/vectors/navigator/rowanholt-layers.json +271 -0
- samarkand-0.1.0/tests/vectors/stix/edges.json +96 -0
- samarkand-0.1.0/tests/vectors/stix/expected-rowanholt.json +430 -0
- samarkand-0.1.0/tests/vectors/stix/expected-shikari.json +146 -0
- samarkand-0.1.0/tests/vectors/stix/ostraka-bundle.json +608 -0
- samarkand-0.1.0/tests/vectors/stix/ostraka-observables.json +374 -0
- samarkand-0.1.0/tests/vectors/stix/rowanholt-groups.json +65 -0
- samarkand-0.1.0/tests/vectors/stix/rowanholt-validity.json +28 -0
- samarkand-0.1.0/tests/vectors/stix/shikari-bundle.json +45 -0
- samarkand-0.1.0/tests/vectors/stix/tlp-markings.json +70 -0
- samarkand-0.1.0/tests/vectors/webhooks/vectors.json +106 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
.venv/
|
|
9
|
+
venv/
|
|
10
|
+
.pytest_cache/
|
|
11
|
+
.ruff_cache/
|
|
12
|
+
.mypy_cache/
|
|
13
|
+
.coverage
|
|
14
|
+
htmlcov/
|
|
15
|
+
|
|
16
|
+
# Never commit a licence, a signing key or any key. The one exception is the throwaway test seed,
|
|
17
|
+
# which no product trusts.
|
|
18
|
+
*.licence
|
|
19
|
+
*licence-signing*
|
|
20
|
+
*.key
|
|
21
|
+
!tests/vectors/licence/THROWAWAY-TEST-SEED.key
|
|
22
|
+
|
|
23
|
+
# Editors and the OS
|
|
24
|
+
.DS_Store
|
|
25
|
+
.idea/
|
|
26
|
+
.vscode/
|
samarkand-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Copyright (c) 2026 cmdaltr. samarkand is licensed under the Elastic License 2.0, below.
|
|
2
|
+
The licensor is Ben Smith (cmdaltr).
|
|
3
|
+
|
|
4
|
+
Elastic License 2.0
|
|
5
|
+
|
|
6
|
+
URL: https://www.elastic.co/licensing/elastic-license
|
|
7
|
+
|
|
8
|
+
## Acceptance
|
|
9
|
+
|
|
10
|
+
By using the software, you agree to all of the terms and conditions below.
|
|
11
|
+
|
|
12
|
+
## Copyright License
|
|
13
|
+
|
|
14
|
+
The licensor grants you a non-exclusive, royalty-free, worldwide,
|
|
15
|
+
non-sublicensable, non-transferable license to use, copy, distribute, make
|
|
16
|
+
available, and prepare derivative works of the software, in each case subject to
|
|
17
|
+
the limitations and conditions below.
|
|
18
|
+
|
|
19
|
+
## Limitations
|
|
20
|
+
|
|
21
|
+
You may not provide the software to third parties as a hosted or managed
|
|
22
|
+
service, where the service provides users with access to any substantial set of
|
|
23
|
+
the features or functionality of the software.
|
|
24
|
+
|
|
25
|
+
You may not move, change, disable, or circumvent the license key functionality
|
|
26
|
+
in the software, and you may not remove or obscure any functionality in the
|
|
27
|
+
software that is protected by the license key.
|
|
28
|
+
|
|
29
|
+
You may not alter, remove, or obscure any licensing, copyright, or other notices
|
|
30
|
+
of the licensor in the software. Any use of the licensor’s trademarks is subject
|
|
31
|
+
to applicable law.
|
|
32
|
+
|
|
33
|
+
## Patents
|
|
34
|
+
|
|
35
|
+
The licensor grants you a license, under any patent claims the licensor can
|
|
36
|
+
license, or becomes able to license, to make, have made, use, sell, offer for
|
|
37
|
+
sale, import and have imported the software, in each case subject to the
|
|
38
|
+
limitations and conditions in this license. This license does not cover any
|
|
39
|
+
patent claims that you cause to be infringed by modifications or additions to
|
|
40
|
+
the software. If you or your company make any written claim that the software
|
|
41
|
+
infringes or contributes to infringement of any patent, your patent license for
|
|
42
|
+
the software granted under these terms ends immediately. If your company makes
|
|
43
|
+
such a claim, your patent license ends immediately for work on behalf of your
|
|
44
|
+
company.
|
|
45
|
+
|
|
46
|
+
## Notices
|
|
47
|
+
|
|
48
|
+
You must ensure that anyone who gets a copy of any part of the software from you
|
|
49
|
+
also gets a copy of these terms.
|
|
50
|
+
|
|
51
|
+
If you modify the software, you must include in any modified copies of the
|
|
52
|
+
software prominent notices stating that you have modified the software.
|
|
53
|
+
|
|
54
|
+
## No Other Rights
|
|
55
|
+
|
|
56
|
+
These terms do not imply any licenses other than those expressly granted in
|
|
57
|
+
these terms.
|
|
58
|
+
|
|
59
|
+
## Termination
|
|
60
|
+
|
|
61
|
+
If you use the software in violation of these terms, such use is not licensed,
|
|
62
|
+
and your licenses will automatically terminate. If the licensor provides you
|
|
63
|
+
with a notice of your violation, and you cease all violation of this license no
|
|
64
|
+
later than 30 days after you receive that notice, your licenses will be
|
|
65
|
+
reinstated retroactively. However, if you violate these terms after such
|
|
66
|
+
reinstatement, any additional violation of these terms will cause your licenses
|
|
67
|
+
to terminate automatically and permanently.
|
|
68
|
+
|
|
69
|
+
## No Liability
|
|
70
|
+
|
|
71
|
+
*As far as the law allows, the software comes as is, without any warranty or
|
|
72
|
+
condition, and the licensor will not be liable to you for any damages arising
|
|
73
|
+
out of these terms or the use or nature of the software, under any kind of
|
|
74
|
+
legal claim.*
|
|
75
|
+
|
|
76
|
+
## Definitions
|
|
77
|
+
|
|
78
|
+
The **licensor** is the entity offering these terms, and the **software** is the
|
|
79
|
+
software the licensor makes available under these terms, including any portion
|
|
80
|
+
of it.
|
|
81
|
+
|
|
82
|
+
**you** refers to the individual or entity agreeing to these terms.
|
|
83
|
+
|
|
84
|
+
**your company** is any legal entity, sole proprietorship, or other kind of
|
|
85
|
+
organization that you work for, plus all organizations that have control over,
|
|
86
|
+
are under the control of, or are under common control with that
|
|
87
|
+
organization. **control** means ownership of substantially all the assets of an
|
|
88
|
+
entity, or the power to direct its management and policies by vote, contract, or
|
|
89
|
+
otherwise. Control can be direct or indirect.
|
|
90
|
+
|
|
91
|
+
**your licenses** are all the licenses granted to you for the software under
|
|
92
|
+
these terms.
|
|
93
|
+
|
|
94
|
+
**use** means anything you do with the software requiring one of your licenses.
|
|
95
|
+
|
|
96
|
+
**trademark** means trademarks, service marks, and similar rights.
|
samarkand-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: samarkand
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The licence, webhook and threat-intelligence wire formats shared by Ostraka, Shikari and Rowanholt
|
|
5
|
+
Author-email: "Ben Smith (cmdaltr)" <cmdaltr@gmail.com>
|
|
6
|
+
License-Expression: Elastic-2.0
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Typing :: Typed
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Requires-Dist: cryptography>=41
|
|
15
|
+
Provides-Extra: async
|
|
16
|
+
Requires-Dist: httpx>=0.27; extra == 'async'
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
19
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
20
|
+
Requires-Dist: ruff>=0.8; extra == 'dev'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# samarkand
|
|
24
|
+
|
|
25
|
+
The wire formats and pure logic that Ostraka, Shikari and Rowanholt each used to carry their own copy
|
|
26
|
+
of, in one package: the licence line, signed webhooks, STIX 2.1, TAXII 2.1, MISP events and ATT&CK
|
|
27
|
+
Navigator layers.
|
|
28
|
+
|
|
29
|
+
The package knows no product. A product passes in what is its own: its id (`ostraka`), its public
|
|
30
|
+
keys, its free seat number, its header prefix (`X-Ostraka`), its event names, its STIX namespace and
|
|
31
|
+
its data, already masked by its own rules.
|
|
32
|
+
|
|
33
|
+
## What's in, and what isn't
|
|
34
|
+
|
|
35
|
+
| Module | Contains |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `samarkand.licence` | The line `<product>-licence-1.<payload>.<signature>`, checked offline against the product's Ed25519 public keys (a tuple, for rotation); validity, 30 days' grace then Free; editions and features (`report-branding`, `brand-library`, `automation`, `managed-service`, extras and `*`); refusals with a message and a code |
|
|
38
|
+
| `samarkand.webhooks` | The `{"event", "sent_at", "data"}` envelope, `sign` and a constant-time `verify`, header names from a prefix, the URL rules, the retry schedule, and delivery through the standard library or httpx |
|
|
39
|
+
| `samarkand.stix` | Reading a bundle into neutral indicators, techniques, groups and reports; building objects and bundles with stable ids and TLP 2.0 markings. Hand-written JSON, no `stix2` |
|
|
40
|
+
| `samarkand.taxii` | A TAXII 2.1 client: a collection's objects, following pagination, with optional Basic auth |
|
|
41
|
+
| `samarkand.misp` | Reading and building MISP events, and pushing one (add, or update by uuid) |
|
|
42
|
+
| `samarkand.navigator` | Navigator layers (format 4.5), with the ATT&CK version taken from the product's data |
|
|
43
|
+
|
|
44
|
+
Out, and staying in each product: where a licence, a webhook or a delivery record is stored; routes
|
|
45
|
+
and UI; seat counting; accepting the commercial terms; which events exist and what their data says;
|
|
46
|
+
masking what leaves the product; which indicators count as confirmed; checking a feature at the point
|
|
47
|
+
of use. There is no signing code for licences: issuing stays with each product's admin script and
|
|
48
|
+
the licensor's private key.
|
|
49
|
+
|
|
50
|
+
## Installing
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install samarkand # cryptography is the only dependency
|
|
54
|
+
pip install "samarkand[async]" # adds httpx, for the async webhook sender and MISP push
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Python 3.10 or later.
|
|
58
|
+
|
|
59
|
+
## Using each module
|
|
60
|
+
|
|
61
|
+
### Licence
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from samarkand import licence
|
|
65
|
+
|
|
66
|
+
RULES = licence.Rules("ostraka", PUBLIC_KEYS, free_seats=4, name="Ostraka")
|
|
67
|
+
|
|
68
|
+
lic = RULES.parse(line) # raises licence.LicenceProblem(message, code)
|
|
69
|
+
licence.check_installable(lic) # refuses one already past its grace
|
|
70
|
+
standing = RULES.current(stored_line) # a line that no longer verifies counts as none
|
|
71
|
+
if standing.has(licence.AUTOMATION):
|
|
72
|
+
...
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
An issuer (the product's `scripts/admin/` signer, never a server) signs `licence.head_of(lic)` with
|
|
76
|
+
the product's private key and joins the line with `licence.line_of(head, signature)`.
|
|
77
|
+
|
|
78
|
+
### Webhooks
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from samarkand import webhooks
|
|
82
|
+
|
|
83
|
+
problem = webhooks.url_problem(url) or webhooks.events_problem(events, EVENTS)
|
|
84
|
+
|
|
85
|
+
result = webhooks.deliver(url, "incident.created", masked_data, prefix="X-Ostraka", secret=secret)
|
|
86
|
+
result = await webhooks.deliver_async(url, "incident.created", masked_data, prefix="X-Ostraka", secret=secret)
|
|
87
|
+
keep(result.as_dict()) # the product keeps the last webhooks.KEEP_DELIVERIES
|
|
88
|
+
|
|
89
|
+
# receiving
|
|
90
|
+
names = webhooks.headers_for("X-Shikari")
|
|
91
|
+
if webhooks.verify(secret, raw_body, request.headers.get(names.signature)):
|
|
92
|
+
delivery = webhooks.parse(raw_body)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### STIX and TAXII
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from samarkand import stix, taxii
|
|
99
|
+
|
|
100
|
+
parsed = stix.parse(bundle) # or taxii.fetch_bundle(collection_url, username=…, password=…)
|
|
101
|
+
for item in parsed.indicators:
|
|
102
|
+
item.value, item.kind, item.stix_type, item.stix_path, item.groups, item.techniques
|
|
103
|
+
|
|
104
|
+
bundle = stix.bundle(stix.Export(namespace=MY_NAMESPACE, key=case_id, producer="Northwind Ltd",
|
|
105
|
+
tlp="amber", created=declared, name=title, indicators=…, techniques=…), now)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
A product with more to say than `bundle()` covers (Ostraka's incidents, notes and sightings) builds
|
|
109
|
+
its own objects with `identity_object`, `attack_pattern_object`, `indicator_object`,
|
|
110
|
+
`relationship_object`, `cyber_observables`, `tlp_marking`, `common` and `object_id`.
|
|
111
|
+
|
|
112
|
+
### MISP
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from samarkand import misp
|
|
116
|
+
|
|
117
|
+
parsed = misp.parse(event_json)
|
|
118
|
+
event = misp.event(info=…, date=…, attributes=[misp.attribute(kind, value, to_ids=True, comment=…)],
|
|
119
|
+
techniques=[("T1059.001", "PowerShell")], tlp="amber", uuid=stable_uuid)
|
|
120
|
+
pushed = misp.push(base_url, api_key, event) # or await misp.push_async(...)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Navigator
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
from samarkand import navigator
|
|
127
|
+
|
|
128
|
+
release = navigator.release_of(enterprise_attack_bundle)
|
|
129
|
+
layer = navigator.build_layer(
|
|
130
|
+
f"{case} (confirmed)",
|
|
131
|
+
navigator.ENTERPRISE,
|
|
132
|
+
{"T1059.001": navigator.Mark(navigator.CONFIRMED_COLOUR, "#3 PowerShell cradle")},
|
|
133
|
+
attack_version=release.version,
|
|
134
|
+
platforms=release.platforms[navigator.ENTERPRISE],
|
|
135
|
+
show_ids=True,
|
|
136
|
+
)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Adopting it in a product
|
|
140
|
+
|
|
141
|
+
`tests/adapters.py` turns samarkand's neutral output into Rowanholt's ten-column rows and Shikari's
|
|
142
|
+
extraction shapes, and the tests prove those come out as each product's own code makes them today.
|
|
143
|
+
A product switching over starts from its adapter. `docs/DIFFERENCES.md` lists every place the three
|
|
144
|
+
products disagreed, which way samarkand went, and what switching changes for each.
|
|
145
|
+
|
|
146
|
+
## Test vectors
|
|
147
|
+
|
|
148
|
+
`tests/vectors/` is the point of the package, and it doesn't change to suit the code. If the package
|
|
149
|
+
disagrees with a vector, the package is wrong, or the change is a wire-format change: then it goes in
|
|
150
|
+
`docs/DIFFERENCES.md` first, with what it changes for each product, and the vector changes with it.
|
|
151
|
+
|
|
152
|
+
- `licence/`: licence lines made by `tests/vectors/make_vectors.py`, signed with the throwaway seeds
|
|
153
|
+
in `THROWAWAY-TEST-SEED.key`. No product trusts them and none ever may: `tests/test_vectors.py`
|
|
154
|
+
checks they aren't any product's key. The script doesn't import samarkand, so the package is tested
|
|
155
|
+
against lines it didn't make. A product checks its own parser by trusting `test_public_key` in
|
|
156
|
+
place of its `PUBLIC_KEYS` and running the same vectors.
|
|
157
|
+
- `webhooks/`: bodies as the bytes sent, each with its signature, and every URL any product's tests
|
|
158
|
+
allow or refuse.
|
|
159
|
+
- `stix/`, `misp/` and `navigator/`: fixtures from the products' own tests, and what each product's
|
|
160
|
+
code produces from them today (`expected-*.json`, `ostraka-*.json`, `rowanholt-layers.json`),
|
|
161
|
+
captured on 11 November 2026.
|
|
162
|
+
|
|
163
|
+
## Development
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
python3 -m venv .venv
|
|
167
|
+
.venv/bin/pip install -e ".[dev]"
|
|
168
|
+
.venv/bin/ruff check . && .venv/bin/ruff format --check .
|
|
169
|
+
.venv/bin/pytest
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
CI (`.github/workflows/test.yml`) runs the same on Python 3.10, 3.11 and 3.12.
|
|
173
|
+
|
|
174
|
+
## Releasing
|
|
175
|
+
|
|
176
|
+
Set the version in `pyproject.toml` and `src/samarkand/__init__.py`, commit, then tag and push:
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
git tag v0.1.0 && git push origin v0.1.0
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
`.github/workflows/release.yml` checks the tag matches both versions, runs the tests, builds, and
|
|
183
|
+
publishes to PyPI through Trusted Publishing: no token is stored anywhere. Products pin an exact
|
|
184
|
+
version (`samarkand==0.1.0`).
|
|
185
|
+
|
|
186
|
+
## Licence
|
|
187
|
+
|
|
188
|
+
Elastic License 2.0 (`LICENSE`); the licensor is Ben Smith (cmdaltr).
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# samarkand
|
|
2
|
+
|
|
3
|
+
The wire formats and pure logic that Ostraka, Shikari and Rowanholt each used to carry their own copy
|
|
4
|
+
of, in one package: the licence line, signed webhooks, STIX 2.1, TAXII 2.1, MISP events and ATT&CK
|
|
5
|
+
Navigator layers.
|
|
6
|
+
|
|
7
|
+
The package knows no product. A product passes in what is its own: its id (`ostraka`), its public
|
|
8
|
+
keys, its free seat number, its header prefix (`X-Ostraka`), its event names, its STIX namespace and
|
|
9
|
+
its data, already masked by its own rules.
|
|
10
|
+
|
|
11
|
+
## What's in, and what isn't
|
|
12
|
+
|
|
13
|
+
| Module | Contains |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `samarkand.licence` | The line `<product>-licence-1.<payload>.<signature>`, checked offline against the product's Ed25519 public keys (a tuple, for rotation); validity, 30 days' grace then Free; editions and features (`report-branding`, `brand-library`, `automation`, `managed-service`, extras and `*`); refusals with a message and a code |
|
|
16
|
+
| `samarkand.webhooks` | The `{"event", "sent_at", "data"}` envelope, `sign` and a constant-time `verify`, header names from a prefix, the URL rules, the retry schedule, and delivery through the standard library or httpx |
|
|
17
|
+
| `samarkand.stix` | Reading a bundle into neutral indicators, techniques, groups and reports; building objects and bundles with stable ids and TLP 2.0 markings. Hand-written JSON, no `stix2` |
|
|
18
|
+
| `samarkand.taxii` | A TAXII 2.1 client: a collection's objects, following pagination, with optional Basic auth |
|
|
19
|
+
| `samarkand.misp` | Reading and building MISP events, and pushing one (add, or update by uuid) |
|
|
20
|
+
| `samarkand.navigator` | Navigator layers (format 4.5), with the ATT&CK version taken from the product's data |
|
|
21
|
+
|
|
22
|
+
Out, and staying in each product: where a licence, a webhook or a delivery record is stored; routes
|
|
23
|
+
and UI; seat counting; accepting the commercial terms; which events exist and what their data says;
|
|
24
|
+
masking what leaves the product; which indicators count as confirmed; checking a feature at the point
|
|
25
|
+
of use. There is no signing code for licences: issuing stays with each product's admin script and
|
|
26
|
+
the licensor's private key.
|
|
27
|
+
|
|
28
|
+
## Installing
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install samarkand # cryptography is the only dependency
|
|
32
|
+
pip install "samarkand[async]" # adds httpx, for the async webhook sender and MISP push
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Python 3.10 or later.
|
|
36
|
+
|
|
37
|
+
## Using each module
|
|
38
|
+
|
|
39
|
+
### Licence
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from samarkand import licence
|
|
43
|
+
|
|
44
|
+
RULES = licence.Rules("ostraka", PUBLIC_KEYS, free_seats=4, name="Ostraka")
|
|
45
|
+
|
|
46
|
+
lic = RULES.parse(line) # raises licence.LicenceProblem(message, code)
|
|
47
|
+
licence.check_installable(lic) # refuses one already past its grace
|
|
48
|
+
standing = RULES.current(stored_line) # a line that no longer verifies counts as none
|
|
49
|
+
if standing.has(licence.AUTOMATION):
|
|
50
|
+
...
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
An issuer (the product's `scripts/admin/` signer, never a server) signs `licence.head_of(lic)` with
|
|
54
|
+
the product's private key and joins the line with `licence.line_of(head, signature)`.
|
|
55
|
+
|
|
56
|
+
### Webhooks
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from samarkand import webhooks
|
|
60
|
+
|
|
61
|
+
problem = webhooks.url_problem(url) or webhooks.events_problem(events, EVENTS)
|
|
62
|
+
|
|
63
|
+
result = webhooks.deliver(url, "incident.created", masked_data, prefix="X-Ostraka", secret=secret)
|
|
64
|
+
result = await webhooks.deliver_async(url, "incident.created", masked_data, prefix="X-Ostraka", secret=secret)
|
|
65
|
+
keep(result.as_dict()) # the product keeps the last webhooks.KEEP_DELIVERIES
|
|
66
|
+
|
|
67
|
+
# receiving
|
|
68
|
+
names = webhooks.headers_for("X-Shikari")
|
|
69
|
+
if webhooks.verify(secret, raw_body, request.headers.get(names.signature)):
|
|
70
|
+
delivery = webhooks.parse(raw_body)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### STIX and TAXII
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from samarkand import stix, taxii
|
|
77
|
+
|
|
78
|
+
parsed = stix.parse(bundle) # or taxii.fetch_bundle(collection_url, username=…, password=…)
|
|
79
|
+
for item in parsed.indicators:
|
|
80
|
+
item.value, item.kind, item.stix_type, item.stix_path, item.groups, item.techniques
|
|
81
|
+
|
|
82
|
+
bundle = stix.bundle(stix.Export(namespace=MY_NAMESPACE, key=case_id, producer="Northwind Ltd",
|
|
83
|
+
tlp="amber", created=declared, name=title, indicators=…, techniques=…), now)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
A product with more to say than `bundle()` covers (Ostraka's incidents, notes and sightings) builds
|
|
87
|
+
its own objects with `identity_object`, `attack_pattern_object`, `indicator_object`,
|
|
88
|
+
`relationship_object`, `cyber_observables`, `tlp_marking`, `common` and `object_id`.
|
|
89
|
+
|
|
90
|
+
### MISP
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from samarkand import misp
|
|
94
|
+
|
|
95
|
+
parsed = misp.parse(event_json)
|
|
96
|
+
event = misp.event(info=…, date=…, attributes=[misp.attribute(kind, value, to_ids=True, comment=…)],
|
|
97
|
+
techniques=[("T1059.001", "PowerShell")], tlp="amber", uuid=stable_uuid)
|
|
98
|
+
pushed = misp.push(base_url, api_key, event) # or await misp.push_async(...)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Navigator
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from samarkand import navigator
|
|
105
|
+
|
|
106
|
+
release = navigator.release_of(enterprise_attack_bundle)
|
|
107
|
+
layer = navigator.build_layer(
|
|
108
|
+
f"{case} (confirmed)",
|
|
109
|
+
navigator.ENTERPRISE,
|
|
110
|
+
{"T1059.001": navigator.Mark(navigator.CONFIRMED_COLOUR, "#3 PowerShell cradle")},
|
|
111
|
+
attack_version=release.version,
|
|
112
|
+
platforms=release.platforms[navigator.ENTERPRISE],
|
|
113
|
+
show_ids=True,
|
|
114
|
+
)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Adopting it in a product
|
|
118
|
+
|
|
119
|
+
`tests/adapters.py` turns samarkand's neutral output into Rowanholt's ten-column rows and Shikari's
|
|
120
|
+
extraction shapes, and the tests prove those come out as each product's own code makes them today.
|
|
121
|
+
A product switching over starts from its adapter. `docs/DIFFERENCES.md` lists every place the three
|
|
122
|
+
products disagreed, which way samarkand went, and what switching changes for each.
|
|
123
|
+
|
|
124
|
+
## Test vectors
|
|
125
|
+
|
|
126
|
+
`tests/vectors/` is the point of the package, and it doesn't change to suit the code. If the package
|
|
127
|
+
disagrees with a vector, the package is wrong, or the change is a wire-format change: then it goes in
|
|
128
|
+
`docs/DIFFERENCES.md` first, with what it changes for each product, and the vector changes with it.
|
|
129
|
+
|
|
130
|
+
- `licence/`: licence lines made by `tests/vectors/make_vectors.py`, signed with the throwaway seeds
|
|
131
|
+
in `THROWAWAY-TEST-SEED.key`. No product trusts them and none ever may: `tests/test_vectors.py`
|
|
132
|
+
checks they aren't any product's key. The script doesn't import samarkand, so the package is tested
|
|
133
|
+
against lines it didn't make. A product checks its own parser by trusting `test_public_key` in
|
|
134
|
+
place of its `PUBLIC_KEYS` and running the same vectors.
|
|
135
|
+
- `webhooks/`: bodies as the bytes sent, each with its signature, and every URL any product's tests
|
|
136
|
+
allow or refuse.
|
|
137
|
+
- `stix/`, `misp/` and `navigator/`: fixtures from the products' own tests, and what each product's
|
|
138
|
+
code produces from them today (`expected-*.json`, `ostraka-*.json`, `rowanholt-layers.json`),
|
|
139
|
+
captured on 11 November 2026.
|
|
140
|
+
|
|
141
|
+
## Development
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
python3 -m venv .venv
|
|
145
|
+
.venv/bin/pip install -e ".[dev]"
|
|
146
|
+
.venv/bin/ruff check . && .venv/bin/ruff format --check .
|
|
147
|
+
.venv/bin/pytest
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
CI (`.github/workflows/test.yml`) runs the same on Python 3.10, 3.11 and 3.12.
|
|
151
|
+
|
|
152
|
+
## Releasing
|
|
153
|
+
|
|
154
|
+
Set the version in `pyproject.toml` and `src/samarkand/__init__.py`, commit, then tag and push:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
git tag v0.1.0 && git push origin v0.1.0
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`.github/workflows/release.yml` checks the tag matches both versions, runs the tests, builds, and
|
|
161
|
+
publishes to PyPI through Trusted Publishing: no token is stored anywhere. Products pin an exact
|
|
162
|
+
version (`samarkand==0.1.0`).
|
|
163
|
+
|
|
164
|
+
## Licence
|
|
165
|
+
|
|
166
|
+
Elastic License 2.0 (`LICENSE`); the licensor is Ben Smith (cmdaltr).
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Where the products disagree
|
|
2
|
+
|
|
3
|
+
Where Ostraka, Shikari and Rowanholt disagree on a wire detail, samarkand picks one. Each entry says
|
|
4
|
+
where each product stands today (file and line, at the commits read on 11 November 2026: Ostraka
|
|
5
|
+
`integration` 65536db, Shikari 64135d73, Rowanholt 69f1c10), what samarkand does, and what switching
|
|
6
|
+
changes for each product.
|
|
7
|
+
|
|
8
|
+
## Licence
|
|
9
|
+
|
|
10
|
+
The three products sign and read the line identically: every vector in
|
|
11
|
+
`tests/vectors/licence/vectors.json` was checked against each product's own `parse` and `sign` (with
|
|
12
|
+
the test key trusted in place of `PUBLIC_KEYS`), and all three give the same licence, the same line
|
|
13
|
+
byte for byte and the same messages with their own name. All three also refuse a lapsed licence at
|
|
14
|
+
install (Ostraka `backend/ostraka/api/installation.py:96`, Shikari `shikari/licensing.py:266`
|
|
15
|
+
`check`, Rowanholt `src/web/backend/licence_routes.py:110` and `manage_licence.py:54`), which is
|
|
16
|
+
`check_installable` here. What differs is behaviour at the edges, not the format:
|
|
17
|
+
|
|
18
|
+
| What | Ostraka | Shikari | Rowanholt | samarkand | Switching changes |
|
|
19
|
+
|---|---|---|---|---|---|
|
|
20
|
+
| A signed payload that is valid JSON but not an object (a list, say) | `licence.py:198` catches `KeyError, ValueError, TypeError`; `payload.get` on a list raises `AttributeError`, which escapes as a 500 | `licensing.py:216`, the same | `licence.py:194`, the same | `UNREADABLE` ("from a newer version"), like any other unreadable payload | Only a licensor-signed oddity reaches this, so nothing a customer could see today |
|
|
21
|
+
| `parse(None)` | `licence.py:165` `text.strip()` raises `AttributeError` | `licensing.py:183` `(text or "")`: "isn't a Shikari licence" | `licence.py:161`, as Ostraka | As Shikari | Ostraka and Rowanholt refuse a missing line cleanly instead of raising |
|
|
22
|
+
| Why a licence was refused | The message only | The message only | The message only | The same message, plus `LicenceProblem.code` (`not-a-licence`, `other-product`, `damaged`, `bad-signature`, `unreadable`, `grants-nothing`, `lapsed`) | Nothing; the code is there for an API that wants to say more than the text |
|
|
23
|
+
| Free seats and the product's name | Module constants (`FREE_SEATS = 4`, "Ostraka") | `FREE_SEATS = 2`, "Shikari" | `FREE_SEATS = 4`, "Rowanholt" | Passed in (`standing(..., free_seats=)`, `Rules(product, keys, free_seats, name)`) | Each product binds a `Rules` once with its own |
|
|
24
|
+
| Signing | `licence.sign` (`licence.py:152`), used by `scripts/admin/sign_licence.py` | `licensing.py:171` | `licence.py:148` | No signing code. `head_of` gives the text to sign and `line_of` assembles the line; the licensing repo's `scripts/licences.py` delegates issuing to each product's `scripts/admin/issue-licence.sh`, so it needs no signer from here | Each product's `sign` becomes `line_of(head_of(lic), key.sign(head_of(lic).encode()))` in its admin script |
|
|
25
|
+
|
|
26
|
+
## Webhooks
|
|
27
|
+
|
|
28
|
+
The envelope, the signature and the retry schedule are the same in all three (three tries, waits of
|
|
29
|
+
2 s and 4 s, stop on a 4xx other than 429, the last 20 kept). `tests/vectors/webhooks/vectors.json`
|
|
30
|
+
was checked against Ostraka's `body_of`, `sign` and `integrations/signing.signature_ok`: the bodies
|
|
31
|
+
and signatures are Ostraka's byte for byte.
|
|
32
|
+
|
|
33
|
+
| What | Ostraka | Shikari | Rowanholt | samarkand | Switching changes |
|
|
34
|
+
|---|---|---|---|---|---|
|
|
35
|
+
| `sent_at` | ISO 8601 in UTC, `utcnow().isoformat()` (`webhooks.py:207` `body_of`) | Epoch seconds as a float, `time.time()` (`webhooks.py:142`) | Epoch seconds (`webhooks.py:192`) | ISO 8601 in UTC (`2026-11-11T12:00:00+00:00`); `Delivery.sent_at_time` reads either | Shikari and Rowanholt: a receiver that reads `sent_at` as a number must read a string. The signature is over the raw body, so verification is unaffected. Ostraka: nothing |
|
|
36
|
+
| User-Agent | `ostraka-webhook/1` (`webhooks.py:220`) | `Shikari-Webhook/1` (`webhooks.py:143`) | `Rowanholt-Webhook/1` (`webhooks.py:193`) | `<Name>-Webhook/1` from the prefix (`Ostraka-Webhook/1`); `user_agent=` overrides | Ostraka's changes case, unless it passes its own |
|
|
37
|
+
| Header names | Hard-coded `X-Ostraka-…`; `integrations/signing.headers_for(product)` capitalises a product id | Hard-coded `X-Shikari-…` | Hard-coded `X-Rowanholt-…` | `headers_for(prefix)` from the prefix the product passes (`X-Ostraka`) | None on the wire |
|
|
38
|
+
| http:// | Allowed to localhost, `*.localhost`, loopback and RFC 1918 (`webhooks.py:69` `url_problem`) | Any host (`webhooks.py:80` `validate`: http or https, anything) | As Ostraka, plus `localhost.localdomain` (`webhooks.py:96` `_private_host`) | As Ostraka; `localhost.localdomain` refused (the strictest set: only Rowanholt allowed it) | Shikari: a plain-http webhook to a public host or a LAN name now fails `url_problem` and is not sent. Rowanholt: `http://localhost.localdomain` likewise |
|
|
39
|
+
| Link-local addresses (169.254/16, fe80::/10) | Refused whatever the scheme (`webhooks.py:80`) | Allowed | Refused for http only (not private), allowed over https | Refused whatever the scheme, IPv4-mapped IPv6 (`::ffff:169.254.169.254`) included, which `ipaddress` before the 2024 security releases doesn't count as link-local by itself | Rowanholt and Shikari: an https webhook to a link-local address (cloud metadata) is refused |
|
|
40
|
+
| Credentials in the URL | Allowed | Allowed | Refused (`webhooks.py:115`) | Refused: "Put credentials in the webhook's secret, not the URL." | Ostraka and Shikari: a URL with `user:pass@` is refused |
|
|
41
|
+
| Checking the URL when sending | No (checked when saved) | No | No | `send` refuses a URL `url_problem` refuses (tries 0, `not sent: …`) | A webhook saved under the old rules, or edited by hand in Shikari's or Rowanholt's JSON store, stops sending until it's fixed |
|
|
42
|
+
| Redirects | Not followed (`notify/channels.py` `fetch`, `follow_redirects=False`) | Followed (`requests.post` default, `webhooks.py:150`) | Not followed (`webhooks.py:187`) | Not followed, sync and async; a 3xx is a final answer (`HTTP 302`) | Shikari: a receiver that redirects now records a failure |
|
|
43
|
+
| Timeout | 15 s (`notify/channels.py` `fetch`) | 10 s (`webhooks.py:150`) | 10 s (`TIMEOUT`) | 10 s (`TIMEOUT`), `timeout=` overrides | Ostraka waits 10 s, not 15, unless it passes 15 |
|
|
44
|
+
| Transport | httpx, async | requests, sync, on a thread | httpx, sync, on a thread | Standard library (sync) or httpx (async, `samarkand[async]`) | Shikari no longer needs requests for webhooks |
|
|
45
|
+
| Error text | `str(exc)[:200]` or the exception's name | `str(e)[:200]` (empty for some errors) | `str(exc)[:200]` | As Ostraka | Shikari: an error with no message records its type rather than "" |
|
|
46
|
+
| `events_problem` message | "Choose events from a, b." | "Choose events from a, b" | "Choose events from a, b" | As Ostraka, with the product's events passed in | Punctuation only |
|
|
47
|
+
|
|
48
|
+
## STIX
|
|
49
|
+
|
|
50
|
+
Reading follows Rowanholt (each value with its own linked groups and techniques); building follows
|
|
51
|
+
Ostraka (hand-written JSON, stable ids, TLP 2.0 markings). `tests/test_stix.py` turns samarkand's
|
|
52
|
+
output into Rowanholt's ten-column rows and Shikari's extraction shape (`tests/adapters.py`) and
|
|
53
|
+
compares them with what each product's own parser gives for every fixture: Rowanholt's rows and
|
|
54
|
+
validity are identical for all five fixtures, and Shikari's extraction is identical but for the
|
|
55
|
+
escaped values below. Ostraka's cyber observables, patterns, TLP markings, identity,
|
|
56
|
+
attack-patterns, indicators and relationships rebuild byte for byte.
|
|
57
|
+
|
|
58
|
+
| What | Ostraka | Shikari | Rowanholt | samarkand | Switching changes |
|
|
59
|
+
|---|---|---|---|---|---|
|
|
60
|
+
| Escapes in pattern strings (`'it\'s.exe'`, `'HKLM\\Run'`) | Writes them (`exports/stix.py` `_quote`) | Not understood: `'([^']*)'` (`parsers/stix_import.py:20`) stops at an escaped quote and leaves `\\` doubled | Undone (`threat_intel/stix.py:29`, then `\'` and `\\` replaced) | As Rowanholt | Shikari: `it\'s.exe` reads as `it's.exe` rather than `it\`, and `C:\\Users` as `C:\Users` (Ostraka's own bundles carry such paths) |
|
|
61
|
+
| Objects without an `id` | Never writes one | Read (its tests' bundles have none) | Skipped: `by_id` keeps only objects with ids (`threat_intel/stix.py:127`) | Read, with `object_id` "" so a product can skip them, as `tests/adapters.py` does for Rowanholt | None, if Rowanholt's adapter skips them as now. Without that, Rowanholt would gain rows from id-less objects |
|
|
62
|
+
| Two objects with one id (a newer version) | Never writes one | Both read | The last read, in the first's place (dict order) | As Rowanholt | Shikari: an IOC that only the older version had is no longer read |
|
|
63
|
+
| Which techniques | Each indicator names its own through `indicates` | Every attack-pattern in the bundle, plus T-numbers in indicator names (`stix_import.py:120`) | Each value's own, through `indicates`, `related-to` or `uses`; else a T-number in its name | Both: `Parsed.techniques` (every attack-pattern) and `Indicator.techniques` (linked) and `named_techniques` | None; each product picks the one it uses today |
|
|
64
|
+
| `confidence` | Writes 15 or 85 | Not read | 0 to 100, but `True` counts as 1 (`threat_intel/stix.py:116`) | 0 to 100, booleans refused | Rowanholt: `"confidence": true` is no confidence, not 1 |
|
|
65
|
+
| Timestamps | Writes `YYYY-MM-DDTHH:MM:SS.mmmZ` | Not read | `datetime.fromisoformat` after replacing Z (`threat_intel/validity.py:46`), which on Python 3.10 refuses fractions that aren't 3 or 6 digits | `parse_time`, any number of fractional digits, the same on 3.10 to 3.12; aware UTC (Rowanholt's adapter drops the zone, as it stores naive UTC) | Rowanholt: `.12Z` or nanosecond times read on 3.10 too |
|
|
66
|
+
| Export | Hand-written; deterministic ids; TLP 2.0 marking on every object; `identity_class` organization | `stix2` library (`parsers/stix_export.py:107`); random ids; no markings; `identity_class` system (`:57`); one indicator ORing up to five IOCs per kind, IPs always `ipv4-addr`, hashes always `SHA-256` (`:20`, `:43`); a custom `x-shikari-detection` per detection | No export | Ostraka's object builders (`identity_object`, `attack_pattern_object`, `indicator_object`, `relationship_object`, `cyber_observables`, `tlp_marking`) and a neutral `bundle()` (one indicator per value, its real observable type, `indicates` relationships, a report) | Shikari: drops the `stix2` dependency; its bundles gain markings, stable ids, one indicator per IOC and correct IPv6 and hash types. `x-shikari-detection` objects stay Shikari's own, added to the objects list |
|
|
67
|
+
| A process's command line | An SCO with an id under Ostraka's namespace | `process:command_line` pattern | Read | The pattern always; the SCO only when the caller passes its namespace (process has no ID contributing properties, so the spec gives no id) | None for Ostraka, which passes its namespace |
|
|
68
|
+
|
|
69
|
+
## TAXII
|
|
70
|
+
|
|
71
|
+
| What | Ostraka | Shikari | Rowanholt | samarkand | Switching changes |
|
|
72
|
+
|---|---|---|---|---|---|
|
|
73
|
+
| Transport | No TAXII client | requests (`parsers/taxii_client.py:35`) | urllib (`threat_intel/stix.py:186`) | urllib, the request URLs Rowanholt's makes (`objects/?limit=…&added_after=…&next=…`) | Shikari no longer needs requests here |
|
|
74
|
+
| Redirects | | Followed, with the Basic auth header | Followed (urllib's default) | Not followed: credentials go only to the URL given | A server that redirects (http to https, say) needs its final URL configured |
|
|
75
|
+
| Errors | | `requests.HTTPError` from `raise_for_status` (`:36`) | `urllib.error.HTTPError` | `TaxiiProblem`, with a message for the admin (credentials refused, HTTP status, not JSON, unreachable) | Each product catches `TaxiiProblem` |
|
|
76
|
+
| Collection URL for `collection_info` | | Without its trailing slash (`:49`) | No `collection_info` | With it, as TAXII 2.1 writes collection URLs | Servers that are strict about the slash answer |
|
|
77
|
+
| User-Agent | | `Shikari (TAXII 2.1 client)` | `Rowanholt (TAXII 2.1 client)` (`threat_intel/stix.py:176`) | `TAXII 2.1 client`; `user_agent=` sets the product's | Pass the old string to keep it |
|
|
78
|
+
| TLS verification | | requests' default (on) | On | On; `verify=False` for one request | None |
|
|
79
|
+
|
|
80
|
+
## MISP
|
|
81
|
+
|
|
82
|
+
Reading follows Shikari's importer; building follows the event shape Shikari's exporter and
|
|
83
|
+
Ostraka's share. `tests/test_misp.py` turns samarkand's reading into Shikari's extraction shape for
|
|
84
|
+
all four fixtures (Shikari's own, Shikari's exported run, Ostraka's exported event and an edge-case
|
|
85
|
+
event) and gets exactly what `parse_misp_event` gives; Ostraka's exported event rebuilds byte for
|
|
86
|
+
byte; Shikari's exported run rebuilds with the same content and two keys in another order.
|
|
87
|
+
|
|
88
|
+
| What | Ostraka | Shikari | Rowanholt | samarkand | Switching changes |
|
|
89
|
+
|---|---|---|---|---|---|
|
|
90
|
+
| Key order in an event | `uuid, info, date, threat_level_id, analysis, distribution, published, timestamp` (`exports/misp.py:178`) | `info, date, analysis, threat_level_id, distribution` (`parsers/misp_export.py:91`) | No MISP | As Ostraka; optional keys left out when not given | Shikari: `threat_level_id` and `analysis` swap places. JSON key order means nothing to MISP |
|
|
91
|
+
| Event uuid | From the incident (UUIDv5), so a second push updates | None: MISP makes one, and every export is a new event | | Given by the caller, or left out | None; Shikari can start passing one to make pushes update |
|
|
92
|
+
| Category of a file name | Artifacts dropped (`exports/misp.py:53`) | Payload delivery (`parsers/misp_export.py:23`) | | `ATTRIBUTE_TYPES` is Ostraka's table (Artifacts dropped); `Attribute` takes any category | None if Shikari keeps building its attributes with its own categories, as the test's `shikari_run` does |
|
|
93
|
+
| SHA-512 | No such kind | `hash_type` by length, 128 gives sha512 | | Both: `Kind.FILE_HASH_SHA512` and `hash_type` | None |
|
|
94
|
+
| TLP | A `tlp:` tag first (`exports/misp.py:155`) | No TLP tag | | `tlp=` adds `tlp:<level>` first; `ParsedEvent.tlp` reads it | Shikari gains a TLP tag only if it passes one |
|
|
95
|
+
| Threat actor | None | A `threat-actor` galaxy and tag | | `threat_actor=`, Shikari's shape | None |
|
|
96
|
+
| Composite attributes | Never writes one | Split only when both type and value contain `|` (`parsers/misp_import.py:51`) | | The same; both halves in `ParsedAttribute.parts` with their kinds | None |
|
|
97
|
+
| Empty values | | Skipped | | Skipped | None |
|
|
98
|
+
| Push | Async httpx: GET `/events/view/<uuid>`, then `/events/edit/<uuid>` or `/events/add` (`exports/misp.py:230`) | Pulls only (`/events/restSearch`) | | Ostraka's add-or-update, sync (stdlib) and async (httpx); an event without a uuid is always added; `events_of` reads a restSearch answer as Shikari does | Ostraka catches `samarkand.misp.MispProblem` instead of its own; its User-Agent becomes `samarkand-misp/1` unless it passes `ostraka-misp/1` |
|
|
99
|
+
|
|
100
|
+
## Navigator
|
|
101
|
+
|
|
102
|
+
`build_layer` is Rowanholt's (`post/mitre/layers.py:45`). Rowanholt's three captured layers and
|
|
103
|
+
Ostraka's two (confirmed only, and with leads) rebuild byte for byte.
|
|
104
|
+
|
|
105
|
+
| What | Ostraka | Shikari | Rowanholt | samarkand | Switching changes |
|
|
106
|
+
|---|---|---|---|---|---|
|
|
107
|
+
| ATT&CK version | The bundled data's major (`exports/navigator.py` `attack_major`) | Hard-coded `"17"` by default (`assessments/export.py:36`) | The pinned release's major (`attack_lookup.version()`) | `attack_version=` is required and the layer names its major; `release_of` reads it from ATT&CK's STIX data | Shikari: its assessment layer names the ATT&CK version of the bundle it loads, not 17 |
|
|
108
|
+
| Navigator version | 5.3.2 | 4.9.0 (`assessments/export.py:53`) | 5.3.2 | 5.3.2; `navigator_version=` overrides | Shikari's layers say 5.3.2 |
|
|
109
|
+
| Platforms | A hard-coded copy of Rowanholt's list (`exports/navigator.py:36`): Ostraka's bundled ATT&CK data has none | No `filters` | Every platform the domain's techniques name | Passed in; `release_of` gives each domain's from the data, revoked and deprecated techniques left out | Ostraka can read them from data instead of keeping a copy; Shikari's layers gain `filters`, so cloud-only techniques aren't hidden |
|
|
110
|
+
| Links | On every technique and at layer level, `[]` when none (`:115`, `:127`) | None | None | Written only when given (`Mark.links`, `links=`) | None for either: Ostraka passes them, Rowanholt doesn't |
|
|
111
|
+
| Scores | None | `score` per technique with its own gradient (1 to 4) | None | `Mark.score` and `gradient=`, written only when given | Shikari's layers also gain `layout`, `sorting`, `filters`, `metadata` and the other keys Rowanholt's carry |
|
|
112
|
+
| Layer-level `metadata` | TLP, incident, source | None | `[]` | `metadata=`, `[]` by default | None |
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "samarkand"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "The licence, webhook and threat-intelligence wire formats shared by Ostraka, Shikari and Rowanholt"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = "Elastic-2.0"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [{ name = "Ben Smith (cmdaltr)", email = "cmdaltr@gmail.com" }]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Programming Language :: Python :: 3",
|
|
12
|
+
"Programming Language :: Python :: 3.10",
|
|
13
|
+
"Programming Language :: Python :: 3.11",
|
|
14
|
+
"Programming Language :: Python :: 3.12",
|
|
15
|
+
"Typing :: Typed",
|
|
16
|
+
]
|
|
17
|
+
# Ed25519 for licences. Everything else is the standard library.
|
|
18
|
+
dependencies = ["cryptography>=41"]
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
# The async webhook sender and MISP push, for products already on httpx
|
|
22
|
+
async = ["httpx>=0.27"]
|
|
23
|
+
dev = ["pytest>=8", "ruff>=0.8", "httpx>=0.27"]
|
|
24
|
+
|
|
25
|
+
[build-system]
|
|
26
|
+
requires = ["hatchling>=1.27"]
|
|
27
|
+
build-backend = "hatchling.build"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
packages = ["src/samarkand"]
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.sdist]
|
|
33
|
+
include = ["src/samarkand", "tests", "docs", "README.md", "LICENSE"]
|
|
34
|
+
|
|
35
|
+
[tool.pytest.ini_options]
|
|
36
|
+
testpaths = ["tests"]
|
|
37
|
+
|
|
38
|
+
[tool.ruff]
|
|
39
|
+
line-length = 110
|
|
40
|
+
target-version = "py310"
|
|
41
|
+
|
|
42
|
+
[tool.ruff.lint]
|
|
43
|
+
select = ["E", "F", "I", "B", "UP", "S", "ASYNC"]
|
|
44
|
+
ignore = ["S101"]
|
|
45
|
+
|
|
46
|
+
[tool.ruff.lint.per-file-ignores]
|
|
47
|
+
# Test vectors use fixed throwaway secrets
|
|
48
|
+
"tests/**" = ["S105", "S106", "S107"]
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"""samarkand: the licence, webhook and threat-intelligence wire formats shared by a family of products.
|
|
2
|
+
Pure logic only: each product keeps its own storage, routes, UI and rules, and passes in
|
|
3
|
+
what's its own (its id, public keys, event names and header prefix)."""
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|