vulnsig 1.0.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.
- vulnsig-1.0.0/PKG-INFO +83 -0
- vulnsig-1.0.0/README.md +66 -0
- vulnsig-1.0.0/pyproject.toml +57 -0
- vulnsig-1.0.0/setup.cfg +4 -0
- vulnsig-1.0.0/src/vulnsig/__init__.py +3 -0
- vulnsig-1.0.0/src/vulnsig/color.py +28 -0
- vulnsig-1.0.0/src/vulnsig/geometry.py +86 -0
- vulnsig-1.0.0/src/vulnsig/parse.py +50 -0
- vulnsig-1.0.0/src/vulnsig/render.py +229 -0
- vulnsig-1.0.0/src/vulnsig/score.py +13 -0
- vulnsig-1.0.0/src/vulnsig/types.py +46 -0
- vulnsig-1.0.0/src/vulnsig.egg-info/PKG-INFO +83 -0
- vulnsig-1.0.0/src/vulnsig.egg-info/SOURCES.txt +15 -0
- vulnsig-1.0.0/src/vulnsig.egg-info/dependency_links.txt +1 -0
- vulnsig-1.0.0/src/vulnsig.egg-info/requires.txt +8 -0
- vulnsig-1.0.0/src/vulnsig.egg-info/top_level.txt +1 -0
- vulnsig-1.0.0/tests/test_vulnsig.py +213 -0
vulnsig-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vulnsig
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Render CVSS vulnerability vectors as expressive SVG glyphs
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://vulnsig.io
|
|
7
|
+
Project-URL: Repository, https://github.com/vulnsig/vulnsig-py
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: cvss==3.6
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest==9.0.2; extra == "dev"
|
|
13
|
+
Requires-Dist: ruff==0.15.0; extra == "dev"
|
|
14
|
+
Requires-Dist: mypy==1.19.1; extra == "dev"
|
|
15
|
+
Requires-Dist: nox==2025.11.12; extra == "dev"
|
|
16
|
+
Requires-Dist: build==1.4.0; extra == "dev"
|
|
17
|
+
|
|
18
|
+
# vulnsig-py
|
|
19
|
+
|
|
20
|
+
Render CVSS vulnerability vectors as expressive SVG glyphs. Each glyph encodes all base metrics visually with shape, color, rings, and texture, so vulnerabilities are recognizable at a glance.
|
|
21
|
+
|
|
22
|
+
Supports CVSS 4.0, 3.1, and 3.0.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install vulnsig
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from vulnsig import render_glyph
|
|
34
|
+
|
|
35
|
+
svg = render_glyph("CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H")
|
|
36
|
+
|
|
37
|
+
# override the score (e.g. if you already have it)
|
|
38
|
+
svg = render_glyph("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H", score=10.0)
|
|
39
|
+
|
|
40
|
+
# control rendered size in pixels (default 120)
|
|
41
|
+
svg = render_glyph("CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N", size=64)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`render_glyph` returns an SVG string ready to embed in HTML or write to a file.
|
|
45
|
+
|
|
46
|
+
## Examples
|
|
47
|
+
|
|
48
|
+
### CVSS 4.0
|
|
49
|
+
|
|
50
|
+
| Glyph | Name | Vector | Score |
|
|
51
|
+
|:-----:|------|--------|------:|
|
|
52
|
+
| <img src="assets/log4shell.svg" width="80"> | Log4Shell | `AV:N AC:L AT:N PR:N UI:N VC:H VI:H VA:H SC:H SI:H SA:H` | 10.0 |
|
|
53
|
+
| <img src="assets/eternalblue.svg" width="80"> | EternalBlue | `AV:N AC:L AT:N PR:N UI:N VC:H VI:H VA:H SC:N SI:N SA:N` | 9.3 |
|
|
54
|
+
| <img src="assets/heartbleed.svg" width="80"> | Heartbleed | `AV:N AC:L AT:N PR:N UI:N VC:H VI:N VA:N SC:L SI:N SA:N` | 8.7 |
|
|
55
|
+
| <img src="assets/spectre.svg" width="80"> | Spectre | `AV:L AC:H AT:P PR:L UI:N VC:H VI:N VA:N SC:H SI:N SA:N` | 5.6 |
|
|
56
|
+
| <img src="assets/xss-stored.svg" width="80"> | XSS Stored | `AV:N AC:L AT:N PR:L UI:P VC:L VI:L VA:N SC:N SI:N SA:N` | 5.1 |
|
|
57
|
+
| <img src="assets/usb-physical.svg" width="80"> | USB Drop | `AV:P AC:L AT:N PR:N UI:N VC:H VI:H VA:H SC:N SI:N SA:N` | 7.3 |
|
|
58
|
+
|
|
59
|
+
### CVSS 3.x
|
|
60
|
+
|
|
61
|
+
| Glyph | Name | Vector | Score |
|
|
62
|
+
|:-----:|------|--------|------:|
|
|
63
|
+
| <img src="assets/log4shell-31.svg" width="80"> | Log4Shell | `AV:N AC:L PR:N UI:N S:C C:H I:H A:H` | 10.0 |
|
|
64
|
+
| <img src="assets/xss-reflected-31.svg" width="80"> | XSS Reflected | `AV:N AC:L PR:N UI:R S:C C:L I:L A:N` | 6.1 |
|
|
65
|
+
|
|
66
|
+
## Visual encoding
|
|
67
|
+
|
|
68
|
+
Each metric maps to a distinct visual channel:
|
|
69
|
+
|
|
70
|
+
| Metric | Channel |
|
|
71
|
+
|--------|---------|
|
|
72
|
+
| Score | Hue — yellow (low) → orange → dark red (high) |
|
|
73
|
+
| AV | Star points — N=8, A=6, L=4, P=3 |
|
|
74
|
+
| AC | Star pointiness — L=sharp, H=blunt |
|
|
75
|
+
| AT | Ring segmentation — N=solid, P=cut pattern |
|
|
76
|
+
| PR | Star outline — N=none, L=thin, H=thick |
|
|
77
|
+
| UI | Perimeter — N=spikes, P=bumps, A=clean |
|
|
78
|
+
| VC/VI/VA | Inner ring brightness per sector |
|
|
79
|
+
| SC/SI/SA | Outer ring band (split when any > 0) |
|
|
80
|
+
|
|
81
|
+
## Requirements
|
|
82
|
+
|
|
83
|
+
Python 3.10+
|
vulnsig-1.0.0/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# vulnsig-py
|
|
2
|
+
|
|
3
|
+
Render CVSS vulnerability vectors as expressive SVG glyphs. Each glyph encodes all base metrics visually with shape, color, rings, and texture, so vulnerabilities are recognizable at a glance.
|
|
4
|
+
|
|
5
|
+
Supports CVSS 4.0, 3.1, and 3.0.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install vulnsig
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from vulnsig import render_glyph
|
|
17
|
+
|
|
18
|
+
svg = render_glyph("CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H")
|
|
19
|
+
|
|
20
|
+
# override the score (e.g. if you already have it)
|
|
21
|
+
svg = render_glyph("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H", score=10.0)
|
|
22
|
+
|
|
23
|
+
# control rendered size in pixels (default 120)
|
|
24
|
+
svg = render_glyph("CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N", size=64)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`render_glyph` returns an SVG string ready to embed in HTML or write to a file.
|
|
28
|
+
|
|
29
|
+
## Examples
|
|
30
|
+
|
|
31
|
+
### CVSS 4.0
|
|
32
|
+
|
|
33
|
+
| Glyph | Name | Vector | Score |
|
|
34
|
+
|:-----:|------|--------|------:|
|
|
35
|
+
| <img src="assets/log4shell.svg" width="80"> | Log4Shell | `AV:N AC:L AT:N PR:N UI:N VC:H VI:H VA:H SC:H SI:H SA:H` | 10.0 |
|
|
36
|
+
| <img src="assets/eternalblue.svg" width="80"> | EternalBlue | `AV:N AC:L AT:N PR:N UI:N VC:H VI:H VA:H SC:N SI:N SA:N` | 9.3 |
|
|
37
|
+
| <img src="assets/heartbleed.svg" width="80"> | Heartbleed | `AV:N AC:L AT:N PR:N UI:N VC:H VI:N VA:N SC:L SI:N SA:N` | 8.7 |
|
|
38
|
+
| <img src="assets/spectre.svg" width="80"> | Spectre | `AV:L AC:H AT:P PR:L UI:N VC:H VI:N VA:N SC:H SI:N SA:N` | 5.6 |
|
|
39
|
+
| <img src="assets/xss-stored.svg" width="80"> | XSS Stored | `AV:N AC:L AT:N PR:L UI:P VC:L VI:L VA:N SC:N SI:N SA:N` | 5.1 |
|
|
40
|
+
| <img src="assets/usb-physical.svg" width="80"> | USB Drop | `AV:P AC:L AT:N PR:N UI:N VC:H VI:H VA:H SC:N SI:N SA:N` | 7.3 |
|
|
41
|
+
|
|
42
|
+
### CVSS 3.x
|
|
43
|
+
|
|
44
|
+
| Glyph | Name | Vector | Score |
|
|
45
|
+
|:-----:|------|--------|------:|
|
|
46
|
+
| <img src="assets/log4shell-31.svg" width="80"> | Log4Shell | `AV:N AC:L PR:N UI:N S:C C:H I:H A:H` | 10.0 |
|
|
47
|
+
| <img src="assets/xss-reflected-31.svg" width="80"> | XSS Reflected | `AV:N AC:L PR:N UI:R S:C C:L I:L A:N` | 6.1 |
|
|
48
|
+
|
|
49
|
+
## Visual encoding
|
|
50
|
+
|
|
51
|
+
Each metric maps to a distinct visual channel:
|
|
52
|
+
|
|
53
|
+
| Metric | Channel |
|
|
54
|
+
|--------|---------|
|
|
55
|
+
| Score | Hue — yellow (low) → orange → dark red (high) |
|
|
56
|
+
| AV | Star points — N=8, A=6, L=4, P=3 |
|
|
57
|
+
| AC | Star pointiness — L=sharp, H=blunt |
|
|
58
|
+
| AT | Ring segmentation — N=solid, P=cut pattern |
|
|
59
|
+
| PR | Star outline — N=none, L=thin, H=thick |
|
|
60
|
+
| UI | Perimeter — N=spikes, P=bumps, A=clean |
|
|
61
|
+
| VC/VI/VA | Inner ring brightness per sector |
|
|
62
|
+
| SC/SI/SA | Outer ring band (split when any > 0) |
|
|
63
|
+
|
|
64
|
+
## Requirements
|
|
65
|
+
|
|
66
|
+
Python 3.10+
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vulnsig"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Render CVSS vulnerability vectors as expressive SVG glyphs"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
|
|
13
|
+
dependencies = [
|
|
14
|
+
"cvss==3.6",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Homepage = "https://vulnsig.io"
|
|
19
|
+
Repository = "https://github.com/vulnsig/vulnsig-py"
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
dev = [
|
|
23
|
+
"pytest==9.0.2",
|
|
24
|
+
"ruff==0.15.0",
|
|
25
|
+
"mypy==1.19.1",
|
|
26
|
+
"nox==2025.11.12",
|
|
27
|
+
"build==1.4.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.packages.find]
|
|
31
|
+
where = ["src"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
testpaths = ["tests"]
|
|
35
|
+
|
|
36
|
+
[tool.ruff]
|
|
37
|
+
line-length = 90
|
|
38
|
+
indent-width = 4
|
|
39
|
+
|
|
40
|
+
[tool.ruff.format]
|
|
41
|
+
quote-style = "single"
|
|
42
|
+
indent-style = "space"
|
|
43
|
+
skip-magic-trailing-comma = false
|
|
44
|
+
line-ending = "auto"
|
|
45
|
+
docstring-code-format = true
|
|
46
|
+
docstring-code-line-length = "dynamic"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
[tool.mypy]
|
|
50
|
+
files = "src/**/*.py"
|
|
51
|
+
show_error_codes = true
|
|
52
|
+
warn_redundant_casts = true
|
|
53
|
+
warn_unused_ignores = true
|
|
54
|
+
warn_unreachable = true
|
|
55
|
+
warn_return_any = true
|
|
56
|
+
warn_unused_configs = true
|
|
57
|
+
# disable_error_code = "misc"
|
vulnsig-1.0.0/setup.cfg
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from .types import HueResult
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def score_to_hue(score: float) -> HueResult:
|
|
5
|
+
w = max(0.0, min(10.0, score)) / 10.0
|
|
6
|
+
|
|
7
|
+
# Light Yellow (low) → Orange (mid) → Dark Red (high)
|
|
8
|
+
# 0 = light yellow hsl(55, 95%, 55%)
|
|
9
|
+
# 5 = orange hsl(25, 95%, 53%)
|
|
10
|
+
# 10 = dark red hsl(0, 80%, 28%)
|
|
11
|
+
if w <= 0.5:
|
|
12
|
+
# light yellow → orange: hue 55 → 25
|
|
13
|
+
hue = 55 - (w / 0.5) * 30
|
|
14
|
+
else:
|
|
15
|
+
# orange → dark red: hue 25 → 0
|
|
16
|
+
hue = 25 - ((w - 0.5) / 0.5) * 25
|
|
17
|
+
|
|
18
|
+
sat = 85 + (1 - w) * 10 # 95% at low → 85% at high
|
|
19
|
+
|
|
20
|
+
# Lightness multiplier: asymmetric — subtle brightening for low scores,
|
|
21
|
+
# aggressive darkening for high scores (8-10 distinguishability)
|
|
22
|
+
# score 0: 1.15, score 5: 1.0, score 10: 0.55
|
|
23
|
+
if w <= 0.5:
|
|
24
|
+
light = 1.0 + (0.5 - w) * 0.3 # low end: 1.0 → 1.15
|
|
25
|
+
else:
|
|
26
|
+
light = 1.0 - (w - 0.5) * 0.9 # high end: 1.0 → 0.55
|
|
27
|
+
|
|
28
|
+
return HueResult(hue=hue, sat=sat, light=light)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from typing import NamedTuple
|
|
3
|
+
|
|
4
|
+
_DEG2RAD = math.pi / 180
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Cut(NamedTuple):
|
|
8
|
+
start_deg: float
|
|
9
|
+
end_deg: float
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def arc_path(
|
|
13
|
+
cx: float,
|
|
14
|
+
cy: float,
|
|
15
|
+
inner_r: float,
|
|
16
|
+
outer_r: float,
|
|
17
|
+
start_deg: float,
|
|
18
|
+
end_deg: float,
|
|
19
|
+
) -> str:
|
|
20
|
+
s = start_deg * _DEG2RAD
|
|
21
|
+
e = end_deg * _DEG2RAD
|
|
22
|
+
la = 1 if (end_deg - start_deg) > 180 else 0
|
|
23
|
+
osx = cx + math.cos(s) * outer_r
|
|
24
|
+
osy = cy + math.sin(s) * outer_r
|
|
25
|
+
oex = cx + math.cos(e) * outer_r
|
|
26
|
+
oey = cy + math.sin(e) * outer_r
|
|
27
|
+
iex = cx + math.cos(e) * inner_r
|
|
28
|
+
iey = cy + math.sin(e) * inner_r
|
|
29
|
+
isx = cx + math.cos(s) * inner_r
|
|
30
|
+
isy = cy + math.sin(s) * inner_r
|
|
31
|
+
return (
|
|
32
|
+
f'M{osx},{osy} A{outer_r},{outer_r} 0 {la},1 {oex},{oey} '
|
|
33
|
+
f'L{iex},{iey} A{inner_r},{inner_r} 0 {la},0 {isx},{isy} Z'
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def star_path(
|
|
38
|
+
cx: float,
|
|
39
|
+
cy: float,
|
|
40
|
+
points: int,
|
|
41
|
+
outer_r: float,
|
|
42
|
+
inner_r: float,
|
|
43
|
+
) -> str:
|
|
44
|
+
d = ''
|
|
45
|
+
for i in range(points):
|
|
46
|
+
oa = (math.pi * 2 * i) / points - math.pi / 2
|
|
47
|
+
ia = (math.pi * 2 * (i + 0.5)) / points - math.pi / 2
|
|
48
|
+
ox = cx + math.cos(oa) * outer_r
|
|
49
|
+
oy = cy + math.sin(oa) * outer_r
|
|
50
|
+
ix = cx + math.cos(ia) * inner_r
|
|
51
|
+
iy = cy + math.sin(ia) * inner_r
|
|
52
|
+
if i == 0:
|
|
53
|
+
d += f'M{ox},{oy}'
|
|
54
|
+
else:
|
|
55
|
+
d += f'L{ox},{oy}'
|
|
56
|
+
d += f'L{ix},{iy}'
|
|
57
|
+
return d + 'Z'
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def radial_cuts(
|
|
61
|
+
start_deg: float,
|
|
62
|
+
end_deg: float,
|
|
63
|
+
cut_width: float,
|
|
64
|
+
gap_deg: float,
|
|
65
|
+
) -> list[Cut]:
|
|
66
|
+
cuts: list[Cut] = []
|
|
67
|
+
sector_span = end_deg - start_deg
|
|
68
|
+
step = cut_width + gap_deg
|
|
69
|
+
# Number of visible segments = num_cuts + 1, each gap_deg wide.
|
|
70
|
+
# Total = num_cuts * cut_width + (num_cuts + 1) * gap_deg = sector_span
|
|
71
|
+
# Solve: num_cuts = floor((sector_span - gap_deg) / step)
|
|
72
|
+
num_cuts = int((sector_span - gap_deg) / step)
|
|
73
|
+
pattern_span = num_cuts * cut_width + (num_cuts + 1) * gap_deg
|
|
74
|
+
offset = (sector_span - pattern_span) / 2
|
|
75
|
+
for i in range(num_cuts):
|
|
76
|
+
cut_start = start_deg + offset + (i + 1) * gap_deg + i * cut_width
|
|
77
|
+
cuts.append(Cut(start_deg=cut_start, end_deg=cut_start + cut_width))
|
|
78
|
+
return cuts
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def ring_fill(magnitude: float, hue: float, sat: float, light: float = 1.0) -> str:
|
|
82
|
+
if magnitude <= 0.01:
|
|
83
|
+
return f'hsla({hue}, {sat * 0.1}%, {12 * light}%, 0.9)'
|
|
84
|
+
if magnitude <= 0.5:
|
|
85
|
+
return f'hsla({hue}, {sat * 0.5}%, {35 * light}%, 0.92)'
|
|
86
|
+
return f'hsla({hue}, {sat * 0.9}%, {58 * light}%, 0.95)'
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from .types import MetricKey, ParsedMetrics
|
|
2
|
+
|
|
3
|
+
METRIC_DEFS: dict[MetricKey, dict[str, float]] = {
|
|
4
|
+
'AV': {'N': 1.0, 'A': 0.7, 'L': 0.4, 'P': 0.15},
|
|
5
|
+
'AC': {'L': 1.0, 'H': 0.4},
|
|
6
|
+
'AT': {'N': 1.0, 'P': 0.4},
|
|
7
|
+
'PR': {'N': 1.0, 'L': 0.6, 'H': 0.2},
|
|
8
|
+
'UI': {'N': 1.0, 'P': 0.6, 'A': 0.2, 'R': 0.2}, # R for CVSS 3.0/3.1
|
|
9
|
+
'VC': {'H': 1.0, 'L': 0.5, 'N': 0.0},
|
|
10
|
+
'VI': {'H': 1.0, 'L': 0.5, 'N': 0.0},
|
|
11
|
+
'VA': {'H': 1.0, 'L': 0.5, 'N': 0.0},
|
|
12
|
+
'SC': {'H': 1.0, 'L': 0.5, 'N': 0.0},
|
|
13
|
+
'SI': {'H': 1.0, 'L': 0.5, 'N': 0.0},
|
|
14
|
+
'SA': {'H': 1.0, 'L': 0.5, 'N': 0.0},
|
|
15
|
+
# CVSS 3.0/3.1 metrics (C/I/A without V prefix, S for scope)
|
|
16
|
+
'C': {'H': 1.0, 'L': 0.5, 'N': 0.0},
|
|
17
|
+
'I': {'H': 1.0, 'L': 0.5, 'N': 0.0},
|
|
18
|
+
'A': {'H': 1.0, 'L': 0.5, 'N': 0.0},
|
|
19
|
+
'S': {'C': 1.0, 'U': 0.0}, # Scope: Changed or Unchanged
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse_cvss(vector: str) -> ParsedMetrics:
|
|
24
|
+
m: dict[str, str] = {}
|
|
25
|
+
for part in vector.split('/'):
|
|
26
|
+
if ':' in part:
|
|
27
|
+
key, val = part.split(':', 1)
|
|
28
|
+
if key in METRIC_DEFS:
|
|
29
|
+
m[key] = val
|
|
30
|
+
return m # type: ignore[return-value]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def detect_cvss_version(vector: str) -> str:
|
|
34
|
+
if vector.startswith('CVSS:3.1/'):
|
|
35
|
+
return '3.1'
|
|
36
|
+
elif vector.startswith('CVSS:3.0/'):
|
|
37
|
+
return '3.0'
|
|
38
|
+
elif vector.startswith('CVSS:4.0/'):
|
|
39
|
+
return '4.0'
|
|
40
|
+
raise ValueError(
|
|
41
|
+
"Unsupported CVSS version. Vector must start with 'CVSS:3.0/', 'CVSS:3.1/', or 'CVSS:4.0/'"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def is_version3(version: str) -> bool:
|
|
46
|
+
return version in ('3.0', '3.1')
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_severity(metrics: ParsedMetrics, key: MetricKey) -> float:
|
|
50
|
+
return METRIC_DEFS[key][metrics[key]]
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import ctypes
|
|
2
|
+
import math
|
|
3
|
+
from typing import NamedTuple
|
|
4
|
+
|
|
5
|
+
from .color import score_to_hue
|
|
6
|
+
from .geometry import arc_path, radial_cuts, ring_fill, star_path
|
|
7
|
+
from .parse import detect_cvss_version, get_severity, is_version3, parse_cvss
|
|
8
|
+
from .score import calculate_score
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Sector(NamedTuple):
|
|
12
|
+
key: str
|
|
13
|
+
s: float # start angle (degrees)
|
|
14
|
+
e: float # end angle (degrees)
|
|
15
|
+
vuln: float
|
|
16
|
+
sub: float
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def render_glyph(vector: str, score: float | None = None, size: int = 120) -> str:
|
|
20
|
+
metrics = parse_cvss(vector)
|
|
21
|
+
version = detect_cvss_version(vector)
|
|
22
|
+
|
|
23
|
+
# Score precedence: explicit → auto-calculate
|
|
24
|
+
if score is None:
|
|
25
|
+
score = calculate_score(vector)
|
|
26
|
+
|
|
27
|
+
hue_result = score_to_hue(score)
|
|
28
|
+
hue = hue_result['hue']
|
|
29
|
+
sat = hue_result['sat']
|
|
30
|
+
light = hue_result['light']
|
|
31
|
+
|
|
32
|
+
# Metric severities - handle CVSS 3.0, 3.1, and 4.0
|
|
33
|
+
ac = get_severity(metrics, 'AC')
|
|
34
|
+
|
|
35
|
+
# For CVSS 3.0/3.1, AT doesn't exist, so always treat as solid (AT:N)
|
|
36
|
+
at = 1.0 if is_version3(version) else get_severity(metrics, 'AT')
|
|
37
|
+
|
|
38
|
+
# For CVSS 3.0/3.1, use C/I/A instead of VC/VI/VA
|
|
39
|
+
vc = (
|
|
40
|
+
get_severity(metrics, 'C')
|
|
41
|
+
if is_version3(version)
|
|
42
|
+
else get_severity(metrics, 'VC')
|
|
43
|
+
)
|
|
44
|
+
vi = (
|
|
45
|
+
get_severity(metrics, 'I')
|
|
46
|
+
if is_version3(version)
|
|
47
|
+
else get_severity(metrics, 'VI')
|
|
48
|
+
)
|
|
49
|
+
va = (
|
|
50
|
+
get_severity(metrics, 'A')
|
|
51
|
+
if is_version3(version)
|
|
52
|
+
else get_severity(metrics, 'VA')
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# For CVSS 3.0/3.1, if S:C (Changed), both bands mirror C/I/A. If S:U (Unchanged), no split.
|
|
56
|
+
if is_version3(version):
|
|
57
|
+
scope_changed = get_severity(metrics, 'S') > 0.5 # S:C = 1.0, S:U = 0.0
|
|
58
|
+
if scope_changed:
|
|
59
|
+
# Split band: both bands mirror C/I/A
|
|
60
|
+
sc, si, sa = vc, vi, va
|
|
61
|
+
else:
|
|
62
|
+
# No split
|
|
63
|
+
sc = si = sa = 0.0
|
|
64
|
+
else:
|
|
65
|
+
# CVSS 4.0: use SC/SI/SA directly
|
|
66
|
+
sc = get_severity(metrics, 'SC')
|
|
67
|
+
si = get_severity(metrics, 'SI')
|
|
68
|
+
sa = get_severity(metrics, 'SA')
|
|
69
|
+
|
|
70
|
+
has_any_sub = sc > 0 or si > 0 or sa > 0
|
|
71
|
+
at_present = at < 0.5
|
|
72
|
+
|
|
73
|
+
cx = cy = 60.0
|
|
74
|
+
petal_count = {'N': 8, 'A': 6, 'L': 4, 'P': 3}.get(metrics.get('AV', ''), 8)
|
|
75
|
+
|
|
76
|
+
# Geometry constants
|
|
77
|
+
ring_width = 4.375
|
|
78
|
+
ring_gap = 1.5
|
|
79
|
+
outer_r = 44.0
|
|
80
|
+
hue_ring_r = outer_r + ring_gap + ring_width / 2
|
|
81
|
+
|
|
82
|
+
sub_inner_r = outer_r - ring_width
|
|
83
|
+
vuln_outer_r = sub_inner_r - ring_gap
|
|
84
|
+
vuln_inner_r = vuln_outer_r - ring_width
|
|
85
|
+
inner_r = vuln_inner_r
|
|
86
|
+
|
|
87
|
+
gap_deg = 3.0
|
|
88
|
+
cut_gap_deg = 4.0
|
|
89
|
+
cut_width_deg = 3.0
|
|
90
|
+
|
|
91
|
+
star_outer_r = inner_r - 2
|
|
92
|
+
star_inner_r = star_outer_r * (0.55 - ac * 0.35)
|
|
93
|
+
|
|
94
|
+
# PR stroke
|
|
95
|
+
pr_raw = metrics.get('PR', '')
|
|
96
|
+
pr_stroke_width = 3.2 if pr_raw == 'H' else (1.0 if pr_raw == 'L' else 0.0)
|
|
97
|
+
|
|
98
|
+
# UI spikes/bumps
|
|
99
|
+
ui_raw = metrics.get('UI', '')
|
|
100
|
+
spike_base = hue_ring_r + ring_width / 2 - 0.5
|
|
101
|
+
|
|
102
|
+
# Star fill — match the outer hue ring color
|
|
103
|
+
sf_sat = sat
|
|
104
|
+
sf_light = 52 * light
|
|
105
|
+
sf_alpha = 0.85
|
|
106
|
+
|
|
107
|
+
bg_color = f'hsl({hue}, 4%, 5%)'
|
|
108
|
+
|
|
109
|
+
# Deterministic gradient ID from vector hash
|
|
110
|
+
grad_id = 'sg-' + _simple_hash(vector)
|
|
111
|
+
|
|
112
|
+
# Sectors
|
|
113
|
+
sectors = [
|
|
114
|
+
Sector('C', s=-150 + gap_deg / 2, e=-30 - gap_deg / 2, vuln=vc, sub=sc),
|
|
115
|
+
Sector('I', s=-30 + gap_deg / 2, e=90 - gap_deg / 2, vuln=vi, sub=si),
|
|
116
|
+
Sector('A', s=90 + gap_deg / 2, e=210 - gap_deg / 2, vuln=va, sub=sa),
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
parts: list[str] = []
|
|
120
|
+
|
|
121
|
+
# Defs
|
|
122
|
+
parts.append(f'<defs><radialGradient id="{grad_id}" cx="50%" cy="50%" r="50%">')
|
|
123
|
+
parts.append(
|
|
124
|
+
f'<stop offset="0%" stop-color="hsla({hue}, {sf_sat * 1.1}%, {sf_light + 6}%, {min(1.0, sf_alpha + 0.1)})"/>'
|
|
125
|
+
)
|
|
126
|
+
parts.append(
|
|
127
|
+
f'<stop offset="100%" stop-color="hsla({hue}, {sf_sat}%, {sf_light}%, {sf_alpha})"/>'
|
|
128
|
+
)
|
|
129
|
+
parts.append('</radialGradient></defs>')
|
|
130
|
+
|
|
131
|
+
# Z-order 1: UI:N Spikes
|
|
132
|
+
if ui_raw == 'N':
|
|
133
|
+
for i in range(petal_count):
|
|
134
|
+
a = (math.pi * 2 * i) / petal_count - math.pi / 2
|
|
135
|
+
x1 = cx + math.cos(a) * spike_base
|
|
136
|
+
y1 = cy + math.sin(a) * spike_base
|
|
137
|
+
x2 = cx + math.cos(a) * (spike_base + 3.4)
|
|
138
|
+
y2 = cy + math.sin(a) * (spike_base + 3.4)
|
|
139
|
+
parts.append(
|
|
140
|
+
f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" '
|
|
141
|
+
f'stroke="hsl({hue}, {sat}%, {52 * light}%)" stroke-width="3.0" stroke-linecap="round"/>'
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Z-order 2: UI:P Bumps
|
|
145
|
+
if ui_raw == 'P':
|
|
146
|
+
bump_r = 4.6
|
|
147
|
+
for i in range(petal_count):
|
|
148
|
+
a = (math.pi * 2 * i) / petal_count - math.pi / 2
|
|
149
|
+
bx = cx + math.cos(a) * spike_base
|
|
150
|
+
by = cy + math.sin(a) * spike_base
|
|
151
|
+
perp_l = a - math.pi / 2
|
|
152
|
+
perp_r = a + math.pi / 2
|
|
153
|
+
x1 = bx + math.cos(perp_l) * bump_r
|
|
154
|
+
y1 = by + math.sin(perp_l) * bump_r
|
|
155
|
+
x2 = bx + math.cos(perp_r) * bump_r
|
|
156
|
+
y2 = by + math.sin(perp_r) * bump_r
|
|
157
|
+
parts.append(
|
|
158
|
+
f'<path d="M{x1},{y1} A{bump_r},{bump_r} 0 0,1 {x2},{y2} Z" '
|
|
159
|
+
f'fill="hsl({hue}, {sat}%, {52 * light}%)"/>'
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Z-order 3: Background circle
|
|
163
|
+
parts.append(f'<circle cx="{cx}" cy="{cy}" r="{inner_r}" fill="{bg_color}"/>')
|
|
164
|
+
|
|
165
|
+
# Z-order 4: Star fill
|
|
166
|
+
star_d = star_path(cx, cy, petal_count, star_outer_r, star_inner_r)
|
|
167
|
+
parts.append(f'<path d="{star_d}" fill="url(#{grad_id})" stroke="none"/>')
|
|
168
|
+
|
|
169
|
+
# Z-order 5: Star stroke (PR:N = no stroke)
|
|
170
|
+
if pr_stroke_width > 0:
|
|
171
|
+
parts.append(
|
|
172
|
+
f'<path d="{star_d}" fill="none" stroke="hsl({hue}, {sat}%, {72 * light}%)" '
|
|
173
|
+
f'stroke-width="{pr_stroke_width}" stroke-linejoin="round"/>'
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# Z-order 6 & 7: CIA ring sectors
|
|
177
|
+
for sec in sectors:
|
|
178
|
+
# Vuln band (inner)
|
|
179
|
+
vuln_band_outer = vuln_outer_r if has_any_sub else outer_r
|
|
180
|
+
parts.append(
|
|
181
|
+
f'<path d="{arc_path(cx, cy, vuln_inner_r, vuln_band_outer, sec.s, sec.e)}" '
|
|
182
|
+
f'fill="{ring_fill(sec.vuln, hue, sat, light)}"/>'
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Sub band (outer) — only when split
|
|
186
|
+
if has_any_sub:
|
|
187
|
+
parts.append(
|
|
188
|
+
f'<path d="{arc_path(cx, cy, sub_inner_r, outer_r, sec.s, sec.e)}" '
|
|
189
|
+
f'fill="{ring_fill(sec.sub, hue, sat, light)}"/>'
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Z-order 8: AT:P radial cuts
|
|
193
|
+
if at_present:
|
|
194
|
+
for sec in sectors:
|
|
195
|
+
cuts = radial_cuts(sec.s, sec.e, cut_width_deg, cut_gap_deg)
|
|
196
|
+
for cut in cuts:
|
|
197
|
+
parts.append(
|
|
198
|
+
f'<path d="{arc_path(cx, cy, vuln_inner_r - 0.5, outer_r + 0.5, cut.start_deg, cut.end_deg)}" '
|
|
199
|
+
f'fill="{bg_color}"/>'
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# Z-order 9: Outer hue ring
|
|
203
|
+
parts.append(
|
|
204
|
+
f'<circle cx="{cx}" cy="{cy}" r="{hue_ring_r}" fill="none" '
|
|
205
|
+
f'stroke="hsl({hue}, {sat}%, {52 * light}%)" stroke-width="{ring_width}"/>'
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
return (
|
|
209
|
+
f'<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}" '
|
|
210
|
+
f'viewBox="0 0 120 120" style="overflow:visible">{"".join(parts)}</svg>'
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _simple_hash(s: str) -> str:
|
|
215
|
+
h = 0
|
|
216
|
+
for ch in s:
|
|
217
|
+
h = ctypes.c_int32((h << 5) - h + ord(ch)).value
|
|
218
|
+
return _to_base36(abs(h))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _to_base36(n: int) -> str:
|
|
222
|
+
if n == 0:
|
|
223
|
+
return '0'
|
|
224
|
+
digits: list[str] = []
|
|
225
|
+
alphabet = '0123456789abcdefghijklmnopqrstuvwxyz'
|
|
226
|
+
while n:
|
|
227
|
+
digits.append(alphabet[n % 36])
|
|
228
|
+
n //= 36
|
|
229
|
+
return ''.join(reversed(digits))
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from cvss import CVSS3, CVSS4 # type: ignore
|
|
2
|
+
|
|
3
|
+
from .parse import detect_cvss_version
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def calculate_score(vector: str) -> float:
|
|
7
|
+
version = detect_cvss_version(vector)
|
|
8
|
+
if version in ('3.0', '3.1'):
|
|
9
|
+
c = CVSS3(vector)
|
|
10
|
+
return float(c.base_score)
|
|
11
|
+
# CVSS 4.0 (validated by detect_cvss_version)
|
|
12
|
+
c = CVSS4(vector)
|
|
13
|
+
return float(c.base_score)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from typing import Literal, TypedDict
|
|
2
|
+
|
|
3
|
+
MetricKey = Literal[
|
|
4
|
+
'AV',
|
|
5
|
+
'AC',
|
|
6
|
+
'AT',
|
|
7
|
+
'PR',
|
|
8
|
+
'UI',
|
|
9
|
+
'VC',
|
|
10
|
+
'VI',
|
|
11
|
+
'VA',
|
|
12
|
+
'SC',
|
|
13
|
+
'SI',
|
|
14
|
+
'SA',
|
|
15
|
+
'C',
|
|
16
|
+
'I',
|
|
17
|
+
'A',
|
|
18
|
+
'S',
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ParsedMetrics(TypedDict, total=False):
|
|
23
|
+
# CVSS 4.0 + 3.x shared
|
|
24
|
+
AV: Literal['N', 'A', 'L', 'P']
|
|
25
|
+
AC: Literal['L', 'H']
|
|
26
|
+
PR: Literal['N', 'L', 'H']
|
|
27
|
+
UI: Literal['N', 'P', 'A', 'R'] # R for CVSS 3.0/3.1
|
|
28
|
+
# CVSS 4.0 only
|
|
29
|
+
AT: Literal['N', 'P']
|
|
30
|
+
VC: Literal['H', 'L', 'N']
|
|
31
|
+
VI: Literal['H', 'L', 'N']
|
|
32
|
+
VA: Literal['H', 'L', 'N']
|
|
33
|
+
SC: Literal['H', 'L', 'N']
|
|
34
|
+
SI: Literal['H', 'L', 'N']
|
|
35
|
+
SA: Literal['H', 'L', 'N']
|
|
36
|
+
# CVSS 3.x only
|
|
37
|
+
C: Literal['H', 'L', 'N']
|
|
38
|
+
I: Literal['H', 'L', 'N'] # noqa: E741
|
|
39
|
+
A: Literal['H', 'L', 'N']
|
|
40
|
+
S: Literal['C', 'U'] # Scope: Changed or Unchanged
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class HueResult(TypedDict):
|
|
44
|
+
hue: float
|
|
45
|
+
sat: float
|
|
46
|
+
light: float # multiplier: >1 lighter (low scores), <1 darker (high scores)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vulnsig
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Render CVSS vulnerability vectors as expressive SVG glyphs
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://vulnsig.io
|
|
7
|
+
Project-URL: Repository, https://github.com/vulnsig/vulnsig-py
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: cvss==3.6
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest==9.0.2; extra == "dev"
|
|
13
|
+
Requires-Dist: ruff==0.15.0; extra == "dev"
|
|
14
|
+
Requires-Dist: mypy==1.19.1; extra == "dev"
|
|
15
|
+
Requires-Dist: nox==2025.11.12; extra == "dev"
|
|
16
|
+
Requires-Dist: build==1.4.0; extra == "dev"
|
|
17
|
+
|
|
18
|
+
# vulnsig-py
|
|
19
|
+
|
|
20
|
+
Render CVSS vulnerability vectors as expressive SVG glyphs. Each glyph encodes all base metrics visually with shape, color, rings, and texture, so vulnerabilities are recognizable at a glance.
|
|
21
|
+
|
|
22
|
+
Supports CVSS 4.0, 3.1, and 3.0.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install vulnsig
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from vulnsig import render_glyph
|
|
34
|
+
|
|
35
|
+
svg = render_glyph("CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H")
|
|
36
|
+
|
|
37
|
+
# override the score (e.g. if you already have it)
|
|
38
|
+
svg = render_glyph("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H", score=10.0)
|
|
39
|
+
|
|
40
|
+
# control rendered size in pixels (default 120)
|
|
41
|
+
svg = render_glyph("CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N", size=64)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`render_glyph` returns an SVG string ready to embed in HTML or write to a file.
|
|
45
|
+
|
|
46
|
+
## Examples
|
|
47
|
+
|
|
48
|
+
### CVSS 4.0
|
|
49
|
+
|
|
50
|
+
| Glyph | Name | Vector | Score |
|
|
51
|
+
|:-----:|------|--------|------:|
|
|
52
|
+
| <img src="assets/log4shell.svg" width="80"> | Log4Shell | `AV:N AC:L AT:N PR:N UI:N VC:H VI:H VA:H SC:H SI:H SA:H` | 10.0 |
|
|
53
|
+
| <img src="assets/eternalblue.svg" width="80"> | EternalBlue | `AV:N AC:L AT:N PR:N UI:N VC:H VI:H VA:H SC:N SI:N SA:N` | 9.3 |
|
|
54
|
+
| <img src="assets/heartbleed.svg" width="80"> | Heartbleed | `AV:N AC:L AT:N PR:N UI:N VC:H VI:N VA:N SC:L SI:N SA:N` | 8.7 |
|
|
55
|
+
| <img src="assets/spectre.svg" width="80"> | Spectre | `AV:L AC:H AT:P PR:L UI:N VC:H VI:N VA:N SC:H SI:N SA:N` | 5.6 |
|
|
56
|
+
| <img src="assets/xss-stored.svg" width="80"> | XSS Stored | `AV:N AC:L AT:N PR:L UI:P VC:L VI:L VA:N SC:N SI:N SA:N` | 5.1 |
|
|
57
|
+
| <img src="assets/usb-physical.svg" width="80"> | USB Drop | `AV:P AC:L AT:N PR:N UI:N VC:H VI:H VA:H SC:N SI:N SA:N` | 7.3 |
|
|
58
|
+
|
|
59
|
+
### CVSS 3.x
|
|
60
|
+
|
|
61
|
+
| Glyph | Name | Vector | Score |
|
|
62
|
+
|:-----:|------|--------|------:|
|
|
63
|
+
| <img src="assets/log4shell-31.svg" width="80"> | Log4Shell | `AV:N AC:L PR:N UI:N S:C C:H I:H A:H` | 10.0 |
|
|
64
|
+
| <img src="assets/xss-reflected-31.svg" width="80"> | XSS Reflected | `AV:N AC:L PR:N UI:R S:C C:L I:L A:N` | 6.1 |
|
|
65
|
+
|
|
66
|
+
## Visual encoding
|
|
67
|
+
|
|
68
|
+
Each metric maps to a distinct visual channel:
|
|
69
|
+
|
|
70
|
+
| Metric | Channel |
|
|
71
|
+
|--------|---------|
|
|
72
|
+
| Score | Hue — yellow (low) → orange → dark red (high) |
|
|
73
|
+
| AV | Star points — N=8, A=6, L=4, P=3 |
|
|
74
|
+
| AC | Star pointiness — L=sharp, H=blunt |
|
|
75
|
+
| AT | Ring segmentation — N=solid, P=cut pattern |
|
|
76
|
+
| PR | Star outline — N=none, L=thin, H=thick |
|
|
77
|
+
| UI | Perimeter — N=spikes, P=bumps, A=clean |
|
|
78
|
+
| VC/VI/VA | Inner ring brightness per sector |
|
|
79
|
+
| SC/SI/SA | Outer ring band (split when any > 0) |
|
|
80
|
+
|
|
81
|
+
## Requirements
|
|
82
|
+
|
|
83
|
+
Python 3.10+
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/vulnsig/__init__.py
|
|
4
|
+
src/vulnsig/color.py
|
|
5
|
+
src/vulnsig/geometry.py
|
|
6
|
+
src/vulnsig/parse.py
|
|
7
|
+
src/vulnsig/render.py
|
|
8
|
+
src/vulnsig/score.py
|
|
9
|
+
src/vulnsig/types.py
|
|
10
|
+
src/vulnsig.egg-info/PKG-INFO
|
|
11
|
+
src/vulnsig.egg-info/SOURCES.txt
|
|
12
|
+
src/vulnsig.egg-info/dependency_links.txt
|
|
13
|
+
src/vulnsig.egg-info/requires.txt
|
|
14
|
+
src/vulnsig.egg-info/top_level.txt
|
|
15
|
+
tests/test_vulnsig.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
vulnsig
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from vulnsig import render_glyph
|
|
7
|
+
from vulnsig.color import score_to_hue
|
|
8
|
+
from vulnsig.parse import detect_cvss_version, is_version3, parse_cvss
|
|
9
|
+
from vulnsig.score import calculate_score
|
|
10
|
+
|
|
11
|
+
LOG4SHELL = 'CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H'
|
|
12
|
+
|
|
13
|
+
# CVSS 3.1 test vectors
|
|
14
|
+
CVSS31_LOG4SHELL = 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H'
|
|
15
|
+
CVSS31_HEARTBLEED = 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N'
|
|
16
|
+
CVSS31_DIRTY_COW = 'CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N'
|
|
17
|
+
CVSS31_XSS = 'CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N'
|
|
18
|
+
|
|
19
|
+
# CVSS 3.0 test vectors (same format as 3.1)
|
|
20
|
+
CVSS30_LOG4SHELL = 'CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H'
|
|
21
|
+
CVSS30_HEARTBLEED = 'CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N'
|
|
22
|
+
CVSS30_XSS = 'CVSS:3.0/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N'
|
|
23
|
+
|
|
24
|
+
_TEST_VECTORS_PATH = Path(__file__).parent.parent / 'spec' / 'test-vectors.json'
|
|
25
|
+
with _TEST_VECTORS_PATH.open() as _f:
|
|
26
|
+
TEST_VECTORS = json.load(_f)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# parse_cvss
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TestParseCVSS:
|
|
35
|
+
def test_parses_full_vector(self):
|
|
36
|
+
m = parse_cvss(LOG4SHELL)
|
|
37
|
+
assert m['AV'] == 'N'
|
|
38
|
+
assert m['AC'] == 'L'
|
|
39
|
+
assert m['SC'] == 'H'
|
|
40
|
+
|
|
41
|
+
def test_handles_missing_optional_metrics(self):
|
|
42
|
+
m = parse_cvss('CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H')
|
|
43
|
+
assert m['AV'] == 'N'
|
|
44
|
+
assert m.get('SC') is None
|
|
45
|
+
|
|
46
|
+
def test_parses_cvss31_vector(self):
|
|
47
|
+
m = parse_cvss(CVSS31_LOG4SHELL)
|
|
48
|
+
assert m['AV'] == 'N'
|
|
49
|
+
assert m['AC'] == 'L'
|
|
50
|
+
assert m['C'] == 'H'
|
|
51
|
+
assert m['I'] == 'H'
|
|
52
|
+
assert m['A'] == 'H'
|
|
53
|
+
assert m['S'] == 'C'
|
|
54
|
+
|
|
55
|
+
def test_parses_cvss30_vector(self):
|
|
56
|
+
m = parse_cvss(CVSS30_LOG4SHELL)
|
|
57
|
+
assert m['AV'] == 'N'
|
|
58
|
+
assert m['AC'] == 'L'
|
|
59
|
+
assert m['C'] == 'H'
|
|
60
|
+
assert m['I'] == 'H'
|
|
61
|
+
assert m['A'] == 'H'
|
|
62
|
+
assert m['S'] == 'C'
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# detect_cvss_version
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TestDetectCVSSVersion:
|
|
71
|
+
def test_detects_30(self):
|
|
72
|
+
assert detect_cvss_version(CVSS30_LOG4SHELL) == '3.0'
|
|
73
|
+
|
|
74
|
+
def test_detects_31(self):
|
|
75
|
+
assert detect_cvss_version(CVSS31_LOG4SHELL) == '3.1'
|
|
76
|
+
|
|
77
|
+
def test_detects_40(self):
|
|
78
|
+
assert detect_cvss_version(LOG4SHELL) == '4.0'
|
|
79
|
+
|
|
80
|
+
def test_raises_for_unsupported_version(self):
|
|
81
|
+
with pytest.raises(ValueError, match='Unsupported CVSS version'):
|
|
82
|
+
detect_cvss_version('CVSS:2.0/AV:N/AC:L/Au:N/C:P/I:P/A:P')
|
|
83
|
+
|
|
84
|
+
def test_raises_for_missing_prefix(self):
|
|
85
|
+
with pytest.raises(ValueError, match='Unsupported CVSS version'):
|
|
86
|
+
detect_cvss_version('AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H')
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
# is_version3
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class TestIsVersion3:
|
|
95
|
+
def test_true_for_30(self):
|
|
96
|
+
assert is_version3('3.0') is True
|
|
97
|
+
|
|
98
|
+
def test_true_for_31(self):
|
|
99
|
+
assert is_version3('3.1') is True
|
|
100
|
+
|
|
101
|
+
def test_false_for_40(self):
|
|
102
|
+
assert is_version3('4.0') is False
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
# score_to_hue
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class TestScoreToHue:
|
|
111
|
+
def test_yellow_for_score_0(self):
|
|
112
|
+
assert score_to_hue(0)['hue'] == 55
|
|
113
|
+
|
|
114
|
+
def test_dark_red_for_score_10(self):
|
|
115
|
+
assert score_to_hue(10)['hue'] == 0
|
|
116
|
+
|
|
117
|
+
def test_hue_decreases_with_score(self):
|
|
118
|
+
assert score_to_hue(0)['hue'] > score_to_hue(10)['hue']
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
# calculate_score
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class TestCalculateScore:
|
|
127
|
+
def test_log4shell_is_10(self):
|
|
128
|
+
assert calculate_score(LOG4SHELL) == 10.0
|
|
129
|
+
|
|
130
|
+
def test_invalid_vector_returns_5(self):
|
|
131
|
+
with pytest.raises(ValueError):
|
|
132
|
+
calculate_score('garbage')
|
|
133
|
+
|
|
134
|
+
def test_cvss31_log4shell(self):
|
|
135
|
+
assert calculate_score(CVSS31_LOG4SHELL) == 10.0
|
|
136
|
+
|
|
137
|
+
def test_cvss31_heartbleed(self):
|
|
138
|
+
assert calculate_score(CVSS31_HEARTBLEED) == 7.5
|
|
139
|
+
|
|
140
|
+
def test_cvss31_dirty_cow(self):
|
|
141
|
+
assert calculate_score(CVSS31_DIRTY_COW) == 7.1
|
|
142
|
+
|
|
143
|
+
def test_cvss31_xss(self):
|
|
144
|
+
assert calculate_score(CVSS31_XSS) == 6.1
|
|
145
|
+
|
|
146
|
+
def test_cvss30_log4shell(self):
|
|
147
|
+
assert calculate_score(CVSS30_LOG4SHELL) == 10.0
|
|
148
|
+
|
|
149
|
+
def test_cvss30_heartbleed(self):
|
|
150
|
+
assert calculate_score(CVSS30_HEARTBLEED) == 7.5
|
|
151
|
+
|
|
152
|
+
def test_cvss30_xss(self):
|
|
153
|
+
assert calculate_score(CVSS30_XSS) == 6.1
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
# render_glyph
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class TestRenderGlyph:
|
|
162
|
+
def test_returns_valid_svg(self):
|
|
163
|
+
svg = render_glyph(LOG4SHELL, score=10)
|
|
164
|
+
assert svg.startswith('<svg ')
|
|
165
|
+
assert svg.endswith('</svg>')
|
|
166
|
+
|
|
167
|
+
def test_respects_size_parameter(self):
|
|
168
|
+
svg = render_glyph(LOG4SHELL, score=10, size=64)
|
|
169
|
+
assert 'width="64"' in svg
|
|
170
|
+
assert 'height="64"' in svg
|
|
171
|
+
|
|
172
|
+
def test_renders_all_test_vectors(self):
|
|
173
|
+
for tv in TEST_VECTORS:
|
|
174
|
+
svg = render_glyph(tv['vector'], score=tv['score'])
|
|
175
|
+
assert svg.startswith('<svg ')
|
|
176
|
+
assert svg.endswith('</svg>')
|
|
177
|
+
|
|
178
|
+
def test_renders_cvss31_vectors(self):
|
|
179
|
+
for vector in [CVSS31_LOG4SHELL, CVSS31_HEARTBLEED, CVSS31_DIRTY_COW, CVSS31_XSS]:
|
|
180
|
+
svg = render_glyph(vector)
|
|
181
|
+
assert svg.startswith('<svg ')
|
|
182
|
+
assert svg.endswith('</svg>')
|
|
183
|
+
|
|
184
|
+
def test_cvss31_scope_changed_split_band(self):
|
|
185
|
+
svg = render_glyph(CVSS31_LOG4SHELL)
|
|
186
|
+
assert '<svg' in svg
|
|
187
|
+
assert '</svg>' in svg
|
|
188
|
+
|
|
189
|
+
def test_cvss31_scope_unchanged_no_split(self):
|
|
190
|
+
svg = render_glyph(CVSS31_HEARTBLEED)
|
|
191
|
+
assert '<svg' in svg
|
|
192
|
+
assert '</svg>' in svg
|
|
193
|
+
|
|
194
|
+
def test_cvss31_ui_r_clean_perimeter(self):
|
|
195
|
+
svg = render_glyph(CVSS31_XSS)
|
|
196
|
+
assert '<svg' in svg
|
|
197
|
+
assert '</svg>' in svg
|
|
198
|
+
|
|
199
|
+
def test_renders_cvss30_vectors(self):
|
|
200
|
+
for vector in [CVSS30_LOG4SHELL, CVSS30_HEARTBLEED, CVSS30_XSS]:
|
|
201
|
+
svg = render_glyph(vector)
|
|
202
|
+
assert svg.startswith('<svg ')
|
|
203
|
+
assert svg.endswith('</svg>')
|
|
204
|
+
|
|
205
|
+
def test_cvss30_scope_changed_split_band(self):
|
|
206
|
+
svg = render_glyph(CVSS30_LOG4SHELL)
|
|
207
|
+
assert '<svg' in svg
|
|
208
|
+
assert '</svg>' in svg
|
|
209
|
+
|
|
210
|
+
def test_cvss30_scope_unchanged_no_split(self):
|
|
211
|
+
svg = render_glyph(CVSS30_HEARTBLEED)
|
|
212
|
+
assert '<svg' in svg
|
|
213
|
+
assert '</svg>' in svg
|