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 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+
@@ -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"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ from .render import render_glyph
2
+
3
+ __all__ = ['render_glyph']
@@ -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,8 @@
1
+ cvss==3.6
2
+
3
+ [dev]
4
+ pytest==9.0.2
5
+ ruff==0.15.0
6
+ mypy==1.19.1
7
+ nox==2025.11.12
8
+ build==1.4.0
@@ -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