rig-ores 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.
- rig_ores-0.1.0/.gitignore +26 -0
- rig_ores-0.1.0/PKG-INFO +173 -0
- rig_ores-0.1.0/README.md +161 -0
- rig_ores-0.1.0/pyproject.toml +26 -0
- rig_ores-0.1.0/src/rig_ores/__init__.py +21 -0
- rig_ores-0.1.0/src/rig_ores/data/v0_weights.json +13 -0
- rig_ores-0.1.0/src/rig_ores/scoring.py +173 -0
- rig_ores-0.1.0/tests/test_scoring.py +55 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Binaries
|
|
2
|
+
bin/
|
|
3
|
+
dist/
|
|
4
|
+
*.wasm
|
|
5
|
+
|
|
6
|
+
# Generated
|
|
7
|
+
gen/
|
|
8
|
+
|
|
9
|
+
# IDE
|
|
10
|
+
.idea/
|
|
11
|
+
.vscode/
|
|
12
|
+
*.swp
|
|
13
|
+
|
|
14
|
+
# OS
|
|
15
|
+
.DS_Store
|
|
16
|
+
Thumbs.db
|
|
17
|
+
|
|
18
|
+
# Test
|
|
19
|
+
coverage.txt
|
|
20
|
+
coverage.html
|
|
21
|
+
|
|
22
|
+
# Documentation build output
|
|
23
|
+
site/
|
|
24
|
+
|
|
25
|
+
# Superpowers specs and plans (auto-generated, not for version control)
|
|
26
|
+
docs/superpowers/
|
rig_ores-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: rig-ores
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for ORES: local V0 severity-count risk scoring
|
|
5
|
+
Author-email: liad@rig.security, hila@rig.security
|
|
6
|
+
Maintainer-email: liad@rig.security, hila@rig.security
|
|
7
|
+
License-Expression: Apache-2.0
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Provides-Extra: test
|
|
10
|
+
Requires-Dist: pytest; extra == 'test'
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# rig-ores
|
|
14
|
+
|
|
15
|
+
Python client for [ORES](https://github.com/rigsecurity/ores)'s **V0** risk score:
|
|
16
|
+
a severity-count model for identities/assets, ported from rig-security/ml-services'
|
|
17
|
+
`rice/risk_score/risk_score.py`. It runs entirely locally — no `oresd` daemon or
|
|
18
|
+
network call required — so it's suited to notebooks and batch analysis as well as
|
|
19
|
+
services.
|
|
20
|
+
|
|
21
|
+
Given an identity's finding counts by severity (critical / high / medium / low),
|
|
22
|
+
it returns a 0–10 score capped by the identity's most severe tier, plus the named
|
|
23
|
+
contributions ("factors") that produced it.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
Not yet published to PyPI (see below). Until then, install directly from the repo:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# editable install from a local clone
|
|
31
|
+
pip install -e /path/to/ores/clients/python
|
|
32
|
+
|
|
33
|
+
# or straight from GitHub, no clone needed
|
|
34
|
+
pip install "git+https://github.com/rigsecurity/ores.git#subdirectory=clients/python"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
### Single identity
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from rig_ores import score_identity
|
|
43
|
+
|
|
44
|
+
result = score_identity("cluster-123", critical=1, high=2)
|
|
45
|
+
|
|
46
|
+
result.score # 9.42
|
|
47
|
+
result.capped # False
|
|
48
|
+
result.factors # [Factor(feature="primary", tier="critical", contribution=9.0, reasoning="..."), ...]
|
|
49
|
+
|
|
50
|
+
print(result.explain())
|
|
51
|
+
# Score: 9.42 (critical)
|
|
52
|
+
# +9.00 1 critical finding (highest-severity present)
|
|
53
|
+
# +0.35 1 high finding (next tier down)
|
|
54
|
+
# +0.07 1 further high finding(s), diminishing weight
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`explain()` only lists non-zero contributions, ranked highest first — see
|
|
58
|
+
[Examples](#examples) below for the full range of shapes it can take.
|
|
59
|
+
|
|
60
|
+
### Batch
|
|
61
|
+
|
|
62
|
+
`score_batch` takes any iterable of mappings with `id`, `critical`, `high`,
|
|
63
|
+
`medium`, `low` keys (case-insensitive; missing severities default to 0) — a list
|
|
64
|
+
of dicts, or a pandas DataFrame via `df.to_dict("records")`:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from rig_ores import score_batch
|
|
68
|
+
|
|
69
|
+
rows = [
|
|
70
|
+
{"id": "cluster-123", "critical": 1, "high": 2, "medium": 0, "low": 0},
|
|
71
|
+
{"id": "cluster-456", "critical": 0, "high": 0, "medium": 3, "low": 1},
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
for result in score_batch(rows):
|
|
75
|
+
print(result.id, result.score, result.explain())
|
|
76
|
+
|
|
77
|
+
# from a DataFrame
|
|
78
|
+
# score_batch(findings_df.to_dict("records"))
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Examples
|
|
82
|
+
|
|
83
|
+
24 finding-count combinations chosen to exercise every named contribution
|
|
84
|
+
(`primary`, `same_secondary`, `lower_secondary`, `same_tier_rest`,
|
|
85
|
+
`cross_tier_rest`) and both non-capped and capped scores, computed with the
|
|
86
|
+
default weights:
|
|
87
|
+
|
|
88
|
+
| Scenario | critical | high | medium | low | Score | Capped | Features exercised |
|
|
89
|
+
|---|---:|---:|---:|---:|---:|:---:|---|
|
|
90
|
+
| No findings | 0 | 0 | 0 | 0 | 0.00 | no | — |
|
|
91
|
+
| Single critical | 1 | 0 | 0 | 0 | 9.00 | no | primary |
|
|
92
|
+
| Single high | 0 | 1 | 0 | 0 | 7.00 | no | primary |
|
|
93
|
+
| Single medium | 0 | 0 | 1 | 0 | 4.00 | no | primary |
|
|
94
|
+
| Single low | 0 | 0 | 0 | 1 | 2.00 | no | primary |
|
|
95
|
+
| Same secondary: 2 criticals | 2 | 0 | 0 | 0 | 9.70 | no | primary, same_secondary |
|
|
96
|
+
| Same secondary: 2 highs | 0 | 2 | 0 | 0 | 8.05 | no | primary, same_secondary |
|
|
97
|
+
| Same secondary: 2 mediums | 0 | 0 | 2 | 0 | 5.75 | no | primary, same_secondary |
|
|
98
|
+
| Same secondary: 2 lows | 0 | 0 | 0 | 2 | 2.96 | no | primary, same_secondary |
|
|
99
|
+
| Lower secondary: critical + high | 1 | 1 | 0 | 0 | 9.35 | no | primary, lower_secondary |
|
|
100
|
+
| Lower secondary: critical + medium | 1 | 0 | 1 | 0 | 9.25 | no | primary, lower_secondary |
|
|
101
|
+
| Lower secondary: critical + low | 1 | 0 | 0 | 1 | 9.15 | no | primary, lower_secondary |
|
|
102
|
+
| Lower secondary: high + medium | 0 | 1 | 1 | 0 | 7.25 | no | primary, lower_secondary |
|
|
103
|
+
| Lower secondary: high + low | 0 | 1 | 0 | 1 | 7.15 | no | primary, lower_secondary |
|
|
104
|
+
| Lower secondary: medium + low | 0 | 0 | 1 | 1 | 4.15 | no | primary, lower_secondary |
|
|
105
|
+
| Same-tier decay: 4 criticals | 4 | 0 | 0 | 0 | 9.99 | no | primary, same_secondary, same_tier_rest |
|
|
106
|
+
| Same-tier decay: 5 mediums | 0 | 0 | 5 | 0 | 6.50 | yes | primary, same_secondary, same_tier_rest |
|
|
107
|
+
| Cross-tier decay: critical + 3 highs | 1 | 3 | 0 | 0 | 9.46 | no | primary, lower_secondary, cross_tier_rest |
|
|
108
|
+
| Cross-tier decay: critical, 2 medium, 1 low | 1 | 0 | 2 | 1 | 9.33 | no | primary, lower_secondary, cross_tier_rest |
|
|
109
|
+
| Full mix, primary = critical ×2 | 2 | 1 | 1 | 1 | 9.81 | no | primary, same_secondary, cross_tier_rest |
|
|
110
|
+
| Capped: 50 criticals | 50 | 0 | 0 | 0 | 10.00 | yes | primary, same_secondary, same_tier_rest |
|
|
111
|
+
| Capped: 20 mediums | 0 | 0 | 20 | 0 | 6.50 | yes | primary, same_secondary, same_tier_rest |
|
|
112
|
+
| Just under the cap: 20 lows | 0 | 0 | 0 | 20 | 3.50 | no | primary, same_secondary, same_tier_rest |
|
|
113
|
+
| Realistic mixed cluster | 3 | 5 | 10 | 2 | 9.97 | no | primary, same_secondary, same_tier_rest, cross_tier_rest |
|
|
114
|
+
|
|
115
|
+
A couple of these in full, via `.explain()`:
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
score_identity("x", critical=1, high=3)
|
|
119
|
+
# Score: 9.46 (critical)
|
|
120
|
+
# +9.00 1 critical finding (highest-severity present)
|
|
121
|
+
# +0.35 1 high finding (next tier down)
|
|
122
|
+
# +0.11 2 further high finding(s), diminishing weight
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
score_identity("x", critical=0, high=0, medium=20, low=0)
|
|
127
|
+
# Score: 6.50 (medium)
|
|
128
|
+
# +4.00 1 medium finding (highest-severity present)
|
|
129
|
+
# +1.75 a second medium finding
|
|
130
|
+
# +0.99 18 further medium finding(s), diminishing weight
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Note the "just under the cap" row: with enough low-tier findings the decaying
|
|
134
|
+
`same_tier_rest` bonus asymptotically approaches — but by design of the decay
|
|
135
|
+
formula never quite reaches — the tier's ceiling.
|
|
136
|
+
|
|
137
|
+
## Configuring the weights
|
|
138
|
+
|
|
139
|
+
The weights, per-tier ceilings, and decay rate live in
|
|
140
|
+
[`src/rig_ores/data/v0_weights.json`](src/rig_ores/data/v0_weights.json) and are
|
|
141
|
+
loaded once as `DEFAULT_SPEC`. To score against a different set of weights
|
|
142
|
+
(e.g. while tuning), load your own spec file and pass it explicitly — nothing
|
|
143
|
+
in the package needs to change:
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
from rig_ores import load_spec, score_identity
|
|
147
|
+
|
|
148
|
+
spec = load_spec("my_weights.json")
|
|
149
|
+
result = score_identity("cluster-123", critical=1, spec=spec)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
A weights file must have the same shape as `v0_weights.json`: `tiers` (4 names),
|
|
153
|
+
`weights` (5x4 matrix, rows ordered `primary`, `same_secondary`,
|
|
154
|
+
`lower_secondary`, `same_tier_rest`, `cross_tier_rest`), `ceilings` (4 values),
|
|
155
|
+
and `decay_rate`.
|
|
156
|
+
|
|
157
|
+
## Publishing to PyPI
|
|
158
|
+
|
|
159
|
+
Publishing is automated via [`.github/workflows/publish-python.yml`](../../.github/workflows/publish-python.yml):
|
|
160
|
+
pushing a tag matching `rig-ores-v*` builds the package and publishes it to
|
|
161
|
+
PyPI using [Trusted Publishing](https://docs.pypi.org/trusted-publishers/)
|
|
162
|
+
(OIDC — no stored API token).
|
|
163
|
+
|
|
164
|
+
One manual, one-time step is required before the first publish: a PyPI account
|
|
165
|
+
holder (maintainer: liad@rig.security) must create the `rig-ores` project on
|
|
166
|
+
PyPI and register this repo/workflow as a trusted publisher under
|
|
167
|
+
*Publishing* settings, pointing at:
|
|
168
|
+
|
|
169
|
+
- Repository: `rigsecurity/ores`
|
|
170
|
+
- Workflow: `publish-python.yml`
|
|
171
|
+
- Environment: (none)
|
|
172
|
+
|
|
173
|
+
Maintainers: liad@rig.security, hila@rig.security.
|
rig_ores-0.1.0/README.md
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# rig-ores
|
|
2
|
+
|
|
3
|
+
Python client for [ORES](https://github.com/rigsecurity/ores)'s **V0** risk score:
|
|
4
|
+
a severity-count model for identities/assets, ported from rig-security/ml-services'
|
|
5
|
+
`rice/risk_score/risk_score.py`. It runs entirely locally — no `oresd` daemon or
|
|
6
|
+
network call required — so it's suited to notebooks and batch analysis as well as
|
|
7
|
+
services.
|
|
8
|
+
|
|
9
|
+
Given an identity's finding counts by severity (critical / high / medium / low),
|
|
10
|
+
it returns a 0–10 score capped by the identity's most severe tier, plus the named
|
|
11
|
+
contributions ("factors") that produced it.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
Not yet published to PyPI (see below). Until then, install directly from the repo:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# editable install from a local clone
|
|
19
|
+
pip install -e /path/to/ores/clients/python
|
|
20
|
+
|
|
21
|
+
# or straight from GitHub, no clone needed
|
|
22
|
+
pip install "git+https://github.com/rigsecurity/ores.git#subdirectory=clients/python"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
### Single identity
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from rig_ores import score_identity
|
|
31
|
+
|
|
32
|
+
result = score_identity("cluster-123", critical=1, high=2)
|
|
33
|
+
|
|
34
|
+
result.score # 9.42
|
|
35
|
+
result.capped # False
|
|
36
|
+
result.factors # [Factor(feature="primary", tier="critical", contribution=9.0, reasoning="..."), ...]
|
|
37
|
+
|
|
38
|
+
print(result.explain())
|
|
39
|
+
# Score: 9.42 (critical)
|
|
40
|
+
# +9.00 1 critical finding (highest-severity present)
|
|
41
|
+
# +0.35 1 high finding (next tier down)
|
|
42
|
+
# +0.07 1 further high finding(s), diminishing weight
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`explain()` only lists non-zero contributions, ranked highest first — see
|
|
46
|
+
[Examples](#examples) below for the full range of shapes it can take.
|
|
47
|
+
|
|
48
|
+
### Batch
|
|
49
|
+
|
|
50
|
+
`score_batch` takes any iterable of mappings with `id`, `critical`, `high`,
|
|
51
|
+
`medium`, `low` keys (case-insensitive; missing severities default to 0) — a list
|
|
52
|
+
of dicts, or a pandas DataFrame via `df.to_dict("records")`:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from rig_ores import score_batch
|
|
56
|
+
|
|
57
|
+
rows = [
|
|
58
|
+
{"id": "cluster-123", "critical": 1, "high": 2, "medium": 0, "low": 0},
|
|
59
|
+
{"id": "cluster-456", "critical": 0, "high": 0, "medium": 3, "low": 1},
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
for result in score_batch(rows):
|
|
63
|
+
print(result.id, result.score, result.explain())
|
|
64
|
+
|
|
65
|
+
# from a DataFrame
|
|
66
|
+
# score_batch(findings_df.to_dict("records"))
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Examples
|
|
70
|
+
|
|
71
|
+
24 finding-count combinations chosen to exercise every named contribution
|
|
72
|
+
(`primary`, `same_secondary`, `lower_secondary`, `same_tier_rest`,
|
|
73
|
+
`cross_tier_rest`) and both non-capped and capped scores, computed with the
|
|
74
|
+
default weights:
|
|
75
|
+
|
|
76
|
+
| Scenario | critical | high | medium | low | Score | Capped | Features exercised |
|
|
77
|
+
|---|---:|---:|---:|---:|---:|:---:|---|
|
|
78
|
+
| No findings | 0 | 0 | 0 | 0 | 0.00 | no | — |
|
|
79
|
+
| Single critical | 1 | 0 | 0 | 0 | 9.00 | no | primary |
|
|
80
|
+
| Single high | 0 | 1 | 0 | 0 | 7.00 | no | primary |
|
|
81
|
+
| Single medium | 0 | 0 | 1 | 0 | 4.00 | no | primary |
|
|
82
|
+
| Single low | 0 | 0 | 0 | 1 | 2.00 | no | primary |
|
|
83
|
+
| Same secondary: 2 criticals | 2 | 0 | 0 | 0 | 9.70 | no | primary, same_secondary |
|
|
84
|
+
| Same secondary: 2 highs | 0 | 2 | 0 | 0 | 8.05 | no | primary, same_secondary |
|
|
85
|
+
| Same secondary: 2 mediums | 0 | 0 | 2 | 0 | 5.75 | no | primary, same_secondary |
|
|
86
|
+
| Same secondary: 2 lows | 0 | 0 | 0 | 2 | 2.96 | no | primary, same_secondary |
|
|
87
|
+
| Lower secondary: critical + high | 1 | 1 | 0 | 0 | 9.35 | no | primary, lower_secondary |
|
|
88
|
+
| Lower secondary: critical + medium | 1 | 0 | 1 | 0 | 9.25 | no | primary, lower_secondary |
|
|
89
|
+
| Lower secondary: critical + low | 1 | 0 | 0 | 1 | 9.15 | no | primary, lower_secondary |
|
|
90
|
+
| Lower secondary: high + medium | 0 | 1 | 1 | 0 | 7.25 | no | primary, lower_secondary |
|
|
91
|
+
| Lower secondary: high + low | 0 | 1 | 0 | 1 | 7.15 | no | primary, lower_secondary |
|
|
92
|
+
| Lower secondary: medium + low | 0 | 0 | 1 | 1 | 4.15 | no | primary, lower_secondary |
|
|
93
|
+
| Same-tier decay: 4 criticals | 4 | 0 | 0 | 0 | 9.99 | no | primary, same_secondary, same_tier_rest |
|
|
94
|
+
| Same-tier decay: 5 mediums | 0 | 0 | 5 | 0 | 6.50 | yes | primary, same_secondary, same_tier_rest |
|
|
95
|
+
| Cross-tier decay: critical + 3 highs | 1 | 3 | 0 | 0 | 9.46 | no | primary, lower_secondary, cross_tier_rest |
|
|
96
|
+
| Cross-tier decay: critical, 2 medium, 1 low | 1 | 0 | 2 | 1 | 9.33 | no | primary, lower_secondary, cross_tier_rest |
|
|
97
|
+
| Full mix, primary = critical ×2 | 2 | 1 | 1 | 1 | 9.81 | no | primary, same_secondary, cross_tier_rest |
|
|
98
|
+
| Capped: 50 criticals | 50 | 0 | 0 | 0 | 10.00 | yes | primary, same_secondary, same_tier_rest |
|
|
99
|
+
| Capped: 20 mediums | 0 | 0 | 20 | 0 | 6.50 | yes | primary, same_secondary, same_tier_rest |
|
|
100
|
+
| Just under the cap: 20 lows | 0 | 0 | 0 | 20 | 3.50 | no | primary, same_secondary, same_tier_rest |
|
|
101
|
+
| Realistic mixed cluster | 3 | 5 | 10 | 2 | 9.97 | no | primary, same_secondary, same_tier_rest, cross_tier_rest |
|
|
102
|
+
|
|
103
|
+
A couple of these in full, via `.explain()`:
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
score_identity("x", critical=1, high=3)
|
|
107
|
+
# Score: 9.46 (critical)
|
|
108
|
+
# +9.00 1 critical finding (highest-severity present)
|
|
109
|
+
# +0.35 1 high finding (next tier down)
|
|
110
|
+
# +0.11 2 further high finding(s), diminishing weight
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
score_identity("x", critical=0, high=0, medium=20, low=0)
|
|
115
|
+
# Score: 6.50 (medium)
|
|
116
|
+
# +4.00 1 medium finding (highest-severity present)
|
|
117
|
+
# +1.75 a second medium finding
|
|
118
|
+
# +0.99 18 further medium finding(s), diminishing weight
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Note the "just under the cap" row: with enough low-tier findings the decaying
|
|
122
|
+
`same_tier_rest` bonus asymptotically approaches — but by design of the decay
|
|
123
|
+
formula never quite reaches — the tier's ceiling.
|
|
124
|
+
|
|
125
|
+
## Configuring the weights
|
|
126
|
+
|
|
127
|
+
The weights, per-tier ceilings, and decay rate live in
|
|
128
|
+
[`src/rig_ores/data/v0_weights.json`](src/rig_ores/data/v0_weights.json) and are
|
|
129
|
+
loaded once as `DEFAULT_SPEC`. To score against a different set of weights
|
|
130
|
+
(e.g. while tuning), load your own spec file and pass it explicitly — nothing
|
|
131
|
+
in the package needs to change:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from rig_ores import load_spec, score_identity
|
|
135
|
+
|
|
136
|
+
spec = load_spec("my_weights.json")
|
|
137
|
+
result = score_identity("cluster-123", critical=1, spec=spec)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
A weights file must have the same shape as `v0_weights.json`: `tiers` (4 names),
|
|
141
|
+
`weights` (5x4 matrix, rows ordered `primary`, `same_secondary`,
|
|
142
|
+
`lower_secondary`, `same_tier_rest`, `cross_tier_rest`), `ceilings` (4 values),
|
|
143
|
+
and `decay_rate`.
|
|
144
|
+
|
|
145
|
+
## Publishing to PyPI
|
|
146
|
+
|
|
147
|
+
Publishing is automated via [`.github/workflows/publish-python.yml`](../../.github/workflows/publish-python.yml):
|
|
148
|
+
pushing a tag matching `rig-ores-v*` builds the package and publishes it to
|
|
149
|
+
PyPI using [Trusted Publishing](https://docs.pypi.org/trusted-publishers/)
|
|
150
|
+
(OIDC — no stored API token).
|
|
151
|
+
|
|
152
|
+
One manual, one-time step is required before the first publish: a PyPI account
|
|
153
|
+
holder (maintainer: liad@rig.security) must create the `rig-ores` project on
|
|
154
|
+
PyPI and register this repo/workflow as a trusted publisher under
|
|
155
|
+
*Publishing* settings, pointing at:
|
|
156
|
+
|
|
157
|
+
- Repository: `rigsecurity/ores`
|
|
158
|
+
- Workflow: `publish-python.yml`
|
|
159
|
+
- Environment: (none)
|
|
160
|
+
|
|
161
|
+
Maintainers: liad@rig.security, hila@rig.security.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "rig-ores"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python client for ORES: local V0 severity-count risk scoring"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
authors = [
|
|
13
|
+
{ email = "liad@rig.security" },
|
|
14
|
+
{ email = "hila@rig.security" },
|
|
15
|
+
]
|
|
16
|
+
maintainers = [
|
|
17
|
+
{ email = "liad@rig.security" },
|
|
18
|
+
{ email = "hila@rig.security" },
|
|
19
|
+
]
|
|
20
|
+
dependencies = []
|
|
21
|
+
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
test = ["pytest"]
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.wheel]
|
|
26
|
+
packages = ["src/rig_ores"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from rig_ores.scoring import (
|
|
2
|
+
DEFAULT_SPEC,
|
|
3
|
+
Factor,
|
|
4
|
+
ScoreResult,
|
|
5
|
+
TIERS,
|
|
6
|
+
WeightsSpec,
|
|
7
|
+
load_spec,
|
|
8
|
+
score_batch,
|
|
9
|
+
score_identity,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"DEFAULT_SPEC",
|
|
14
|
+
"Factor",
|
|
15
|
+
"ScoreResult",
|
|
16
|
+
"TIERS",
|
|
17
|
+
"WeightsSpec",
|
|
18
|
+
"load_spec",
|
|
19
|
+
"score_batch",
|
|
20
|
+
"score_identity",
|
|
21
|
+
]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"tiers": ["critical", "high", "medium", "low"],
|
|
3
|
+
"feature_rows": ["primary", "same_secondary", "lower_secondary", "same_tier_rest", "cross_tier_rest"],
|
|
4
|
+
"weights": [
|
|
5
|
+
[9, 7, 4, 2],
|
|
6
|
+
[0.7, 1.05, 1.75, 0.96],
|
|
7
|
+
[0, 0.35, 0.25, 0.15],
|
|
8
|
+
[0.39, 0.59, 0.99, 0.54],
|
|
9
|
+
[0.18, 0.15, 0.12, 0.08]
|
|
10
|
+
],
|
|
11
|
+
"ceilings": [10.0, 8.5, 6.5, 3.5],
|
|
12
|
+
"decay_rate": 0.5
|
|
13
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""V0 severity-count risk scoring.
|
|
2
|
+
|
|
3
|
+
Ports the ORES weights matrix from rig-security/ml-services'
|
|
4
|
+
rice/risk_score/risk_score.py: one identity's Critical/High/Medium/Low finding
|
|
5
|
+
counts go in, a 0-10 score capped by the identity's most severe tier comes out,
|
|
6
|
+
along with the named contributions that produced it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from collections.abc import Iterable, Mapping
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from importlib import resources
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class WeightsSpec:
|
|
20
|
+
tiers: tuple[str, ...]
|
|
21
|
+
weights: list[list[float]]
|
|
22
|
+
ceilings: list[float]
|
|
23
|
+
decay_rate: float
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_spec(path: str | Path) -> WeightsSpec:
|
|
27
|
+
text = Path(path).read_text()
|
|
28
|
+
spec = _spec_from_text(text)
|
|
29
|
+
return spec
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _spec_from_text(text: str) -> WeightsSpec:
|
|
33
|
+
raw = json.loads(text)
|
|
34
|
+
spec = WeightsSpec(
|
|
35
|
+
tiers=tuple(raw["tiers"]),
|
|
36
|
+
weights=raw["weights"],
|
|
37
|
+
ceilings=raw["ceilings"],
|
|
38
|
+
decay_rate=raw["decay_rate"],
|
|
39
|
+
)
|
|
40
|
+
return spec
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
DEFAULT_SPEC = _spec_from_text(resources.files("rig_ores").joinpath("data/v0_weights.json").read_text())
|
|
44
|
+
TIERS = DEFAULT_SPEC.tiers
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class Factor:
|
|
49
|
+
feature: str
|
|
50
|
+
tier: str
|
|
51
|
+
contribution: float
|
|
52
|
+
reasoning: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class ScoreResult:
|
|
57
|
+
id: str
|
|
58
|
+
score: float
|
|
59
|
+
capped: bool
|
|
60
|
+
factors: list[Factor]
|
|
61
|
+
|
|
62
|
+
def explain(self) -> str:
|
|
63
|
+
score_display = f"{self.score:.2f}"
|
|
64
|
+
if not self.factors:
|
|
65
|
+
no_findings_explanation = f"Score: {score_display} (no findings)"
|
|
66
|
+
return no_findings_explanation
|
|
67
|
+
|
|
68
|
+
primary_tier = next(factor.tier for factor in self.factors if factor.feature == "primary")
|
|
69
|
+
ranked_factors = sorted(self.factors, key=lambda factor: factor.contribution, reverse=True)
|
|
70
|
+
lines = [f"Score: {score_display} ({primary_tier})"]
|
|
71
|
+
lines += [f" +{factor.contribution:.2f} {factor.reasoning}" for factor in ranked_factors]
|
|
72
|
+
explanation = "\n".join(lines)
|
|
73
|
+
return explanation
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def score_identity(
|
|
77
|
+
id: str,
|
|
78
|
+
critical: int = 0,
|
|
79
|
+
high: int = 0,
|
|
80
|
+
medium: int = 0,
|
|
81
|
+
low: int = 0,
|
|
82
|
+
spec: WeightsSpec = DEFAULT_SPEC,
|
|
83
|
+
) -> ScoreResult:
|
|
84
|
+
result = _score(id, [critical, high, medium, low], spec)
|
|
85
|
+
return result
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def score_batch(rows: Iterable[Mapping[str, object]], spec: WeightsSpec = DEFAULT_SPEC) -> list[ScoreResult]:
|
|
89
|
+
results = [_score_row(row, spec) for row in rows]
|
|
90
|
+
return results
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _score_row(row: Mapping[str, object], spec: WeightsSpec) -> ScoreResult:
|
|
94
|
+
normalized = {str(key).lower(): value for key, value in row.items()}
|
|
95
|
+
result = score_identity(
|
|
96
|
+
id=normalized["id"],
|
|
97
|
+
critical=int(normalized.get("critical", 0)),
|
|
98
|
+
high=int(normalized.get("high", 0)),
|
|
99
|
+
medium=int(normalized.get("medium", 0)),
|
|
100
|
+
low=int(normalized.get("low", 0)),
|
|
101
|
+
spec=spec,
|
|
102
|
+
)
|
|
103
|
+
return result
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _score(id: str, counts: list[int], spec: WeightsSpec) -> ScoreResult:
|
|
107
|
+
if sum(counts) == 0:
|
|
108
|
+
empty_result = ScoreResult(id=id, score=0.0, capped=False, factors=[])
|
|
109
|
+
return empty_result
|
|
110
|
+
|
|
111
|
+
primary_col = next(index for index, count in enumerate(counts) if count > 0)
|
|
112
|
+
primary_count = counts[primary_col]
|
|
113
|
+
same_secondary = primary_count >= 2
|
|
114
|
+
secondary_col = next((index for index in range(primary_col + 1, len(counts)) if counts[index] > 0), None)
|
|
115
|
+
lower_secondary = primary_count == 1 and secondary_col is not None
|
|
116
|
+
|
|
117
|
+
rest_counts = list(counts)
|
|
118
|
+
rest_counts[primary_col] -= 1
|
|
119
|
+
if same_secondary:
|
|
120
|
+
rest_counts[primary_col] -= 1
|
|
121
|
+
if lower_secondary:
|
|
122
|
+
rest_counts[secondary_col] -= 1
|
|
123
|
+
rest_counts = [max(count, 0) for count in rest_counts]
|
|
124
|
+
|
|
125
|
+
contributions = _contributions(primary_col, same_secondary, secondary_col, lower_secondary, rest_counts, spec)
|
|
126
|
+
raw_score = sum(value for _, _, value, _ in contributions)
|
|
127
|
+
ceiling = spec.ceilings[primary_col]
|
|
128
|
+
capped = raw_score > ceiling
|
|
129
|
+
score = round(min(raw_score, ceiling), 2)
|
|
130
|
+
factors = [
|
|
131
|
+
Factor(feature=feature, tier=spec.tiers[tier], contribution=round(value, 4), reasoning=reasoning)
|
|
132
|
+
for feature, tier, value, reasoning in contributions
|
|
133
|
+
if value != 0
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
result = ScoreResult(id=id, score=score, capped=capped, factors=factors)
|
|
137
|
+
return result
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _contributions(
|
|
141
|
+
primary_col: int,
|
|
142
|
+
same_secondary: bool,
|
|
143
|
+
secondary_col: int | None,
|
|
144
|
+
lower_secondary: bool,
|
|
145
|
+
rest_counts: list[int],
|
|
146
|
+
spec: WeightsSpec,
|
|
147
|
+
) -> list[tuple[str, int, float, str]]:
|
|
148
|
+
contributions: list[tuple[str, int, float, str]] = []
|
|
149
|
+
|
|
150
|
+
primary_reasoning = f"1 {spec.tiers[primary_col]} finding (highest-severity present)"
|
|
151
|
+
contributions.append(("primary", primary_col, spec.weights[0][primary_col], primary_reasoning))
|
|
152
|
+
|
|
153
|
+
if same_secondary:
|
|
154
|
+
same_secondary_reasoning = f"a second {spec.tiers[primary_col]} finding"
|
|
155
|
+
contributions.append(("same_secondary", primary_col, spec.weights[1][primary_col], same_secondary_reasoning))
|
|
156
|
+
|
|
157
|
+
if lower_secondary:
|
|
158
|
+
lower_secondary_reasoning = f"1 {spec.tiers[secondary_col]} finding (next tier down)"
|
|
159
|
+
contributions.append(
|
|
160
|
+
("lower_secondary", secondary_col, spec.weights[2][secondary_col], lower_secondary_reasoning)
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
start = 0
|
|
164
|
+
for tier, rest in enumerate(rest_counts):
|
|
165
|
+
if rest > 0:
|
|
166
|
+
decay = (spec.decay_rate**start) * (1 - spec.decay_rate**rest)
|
|
167
|
+
row = "same_tier_rest" if tier == primary_col else "cross_tier_rest"
|
|
168
|
+
weight_row = 3 if row == "same_tier_rest" else 4
|
|
169
|
+
reasoning = f"{rest} further {spec.tiers[tier]} finding(s), diminishing weight"
|
|
170
|
+
contributions.append((row, tier, decay * spec.weights[weight_row][tier], reasoning))
|
|
171
|
+
start += rest
|
|
172
|
+
|
|
173
|
+
return contributions
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from rig_ores import WeightsSpec, score_batch, score_identity
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_no_findings_scores_zero() -> None:
|
|
5
|
+
result = score_identity("id-1")
|
|
6
|
+
assert result.score == 0.0
|
|
7
|
+
assert result.factors == []
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_single_critical_finding() -> None:
|
|
11
|
+
result = score_identity("id-1", critical=1)
|
|
12
|
+
assert result.score == 9.0
|
|
13
|
+
assert result.factors[0].feature == "primary"
|
|
14
|
+
assert result.factors[0].tier == "critical"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_score_caps_at_primary_tier_ceiling() -> None:
|
|
18
|
+
result = score_identity("id-1", critical=50)
|
|
19
|
+
assert result.score == 10.0
|
|
20
|
+
assert result.capped is True
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_custom_spec_overrides_default_weights() -> None:
|
|
24
|
+
custom_spec = WeightsSpec(
|
|
25
|
+
tiers=("critical", "high", "medium", "low"),
|
|
26
|
+
weights=[[5, 5, 5, 5], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]],
|
|
27
|
+
ceilings=[10.0, 10.0, 10.0, 10.0],
|
|
28
|
+
decay_rate=0.5,
|
|
29
|
+
)
|
|
30
|
+
result = score_identity("id-1", critical=1, spec=custom_spec)
|
|
31
|
+
assert result.score == 5.0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_explain_lists_only_nonzero_contributions_highest_first() -> None:
|
|
35
|
+
result = score_identity("id-1", critical=1, high=2)
|
|
36
|
+
lines = result.explain().splitlines()
|
|
37
|
+
assert lines[0] == "Score: 9.42 (critical)"
|
|
38
|
+
assert len(lines) == 1 + len(result.factors)
|
|
39
|
+
assert lines[1].startswith(" +9.00")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_explain_with_no_findings() -> None:
|
|
43
|
+
result = score_identity("id-1")
|
|
44
|
+
assert result.explain() == "Score: 0.00 (no findings)"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_batch_preserves_id_and_order() -> None:
|
|
48
|
+
results = score_batch(
|
|
49
|
+
[
|
|
50
|
+
{"id": "id-1", "critical": 1, "high": 0, "medium": 0, "low": 0},
|
|
51
|
+
{"id": "id-2", "critical": 0, "high": 0, "medium": 3, "low": 0},
|
|
52
|
+
]
|
|
53
|
+
)
|
|
54
|
+
assert [r.id for r in results] == ["id-1", "id-2"]
|
|
55
|
+
assert results[0].score == 9.0
|