capagap 0.1.0__py3-none-any.whl

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.
capagap/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """CapaGap: evidence-aware comparison of static and dynamic capa results."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from capagap.analysis import compare_documents
6
+ from capagap.io import load_document
7
+ from capagap.matrix import compare_matrix
8
+
9
+ __all__ = ["compare_documents", "compare_matrix", "load_document"]
capagap/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from capagap.cli import main
2
+
3
+ raise SystemExit(main())
capagap/analysis.py ADDED
@@ -0,0 +1,265 @@
1
+ """Core comparison engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+
7
+ from capagap.hotspots import build_evidence_hotspots
8
+ from capagap.manifest import RuleManifest
9
+ from capagap.models import (
10
+ CapaDocument,
11
+ Comparison,
12
+ ComparisonSummary,
13
+ Finding,
14
+ RuleRecord,
15
+ )
16
+ from capagap.scoring import priority_label, score_rule, suggested_action
17
+
18
+
19
+ class ComparisonError(ValueError):
20
+ """Raised when two documents cannot be compared responsibly."""
21
+
22
+
23
+ def _major(version: str) -> str | None:
24
+ head = version.split(".", 1)[0]
25
+ return head if head.isdigit() else None
26
+
27
+
28
+ def _filter_rules(
29
+ document: CapaDocument, include_library: bool
30
+ ) -> dict[str, RuleRecord]:
31
+ return {
32
+ name: rule
33
+ for name, rule in document.rules.items()
34
+ if include_library or not rule.library
35
+ }
36
+
37
+
38
+ def _finding(
39
+ status: str, rule: RuleRecord, *, evasion_context: bool = False
40
+ ) -> Finding:
41
+ if status == "unobserved":
42
+ score, reasons = score_rule(rule, evasion_context=evasion_context)
43
+ return Finding(
44
+ status=status,
45
+ rule=rule,
46
+ priority=score,
47
+ priority_label=priority_label(score),
48
+ reasons=reasons,
49
+ action=suggested_action(rule),
50
+ )
51
+ return Finding(status=status, rule=rule, priority=0, priority_label="informational")
52
+
53
+
54
+ def _sort_findings(findings: Iterable[Finding]) -> tuple[Finding, ...]:
55
+ return tuple(
56
+ sorted(findings, key=lambda item: (-item.priority, item.rule.name.lower()))
57
+ )
58
+
59
+
60
+ def compare_documents(
61
+ static: CapaDocument,
62
+ dynamic: CapaDocument,
63
+ *,
64
+ allow_mismatch: bool = False,
65
+ include_library: bool = False,
66
+ ruleset_manifest: RuleManifest | None = None,
67
+ ) -> Comparison:
68
+ """Compare static potential with behavior observed in one dynamic run."""
69
+
70
+ if static.flavor != "static":
71
+ raise ComparisonError(f"first input must be static, got {static.flavor}")
72
+ if dynamic.flavor != "dynamic":
73
+ raise ComparisonError(f"second input must be dynamic, got {dynamic.flavor}")
74
+
75
+ warnings: list[str] = []
76
+ confidence = "high"
77
+ hashes_present = bool(static.sample_sha256 and dynamic.sample_sha256)
78
+ hashes_match = hashes_present and static.sample_sha256 == dynamic.sample_sha256
79
+ if hashes_present and not hashes_match:
80
+ message = (
81
+ "sample SHA-256 values differ; the reports may describe different samples"
82
+ )
83
+ if not allow_mismatch:
84
+ raise ComparisonError(
85
+ message + " (use --allow-mismatch only when this is intentional)"
86
+ )
87
+ warnings.append(message)
88
+ confidence = "low"
89
+ elif not hashes_present:
90
+ warnings.append(
91
+ "one or both reports lack a sample SHA-256; sample identity is unverified"
92
+ )
93
+ confidence = "medium"
94
+
95
+ if static.os != dynamic.os:
96
+ warnings.append(f"OS differs: static={static.os}, dynamic={dynamic.os}")
97
+ confidence = (
98
+ "low"
99
+ if confidence == "medium"
100
+ else "medium"
101
+ if confidence == "high"
102
+ else confidence
103
+ )
104
+ if static.arch != dynamic.arch:
105
+ warnings.append(
106
+ f"architecture differs: static={static.arch}, dynamic={dynamic.arch}"
107
+ )
108
+ confidence = (
109
+ "low"
110
+ if confidence == "medium"
111
+ else "medium"
112
+ if confidence == "high"
113
+ else confidence
114
+ )
115
+
116
+ static_major = _major(static.capa_version)
117
+ dynamic_major = _major(dynamic.capa_version)
118
+ if static_major and dynamic_major and static_major != dynamic_major:
119
+ warnings.append(
120
+ f"capa major versions differ: static={static.capa_version}, dynamic={dynamic.capa_version}"
121
+ )
122
+ confidence = (
123
+ "low"
124
+ if confidence == "medium"
125
+ else "medium"
126
+ if confidence == "high"
127
+ else confidence
128
+ )
129
+
130
+ static_rules = _filter_rules(static, include_library)
131
+ dynamic_rules = _filter_rules(dynamic, include_library)
132
+ raw_comparable = {
133
+ name: rule for name, rule in static_rules.items() if rule.dynamically_comparable
134
+ }
135
+ static_only_rules = {
136
+ name: rule
137
+ for name, rule in static_rules.items()
138
+ if not rule.dynamically_comparable
139
+ }
140
+
141
+ ruleset_unverified: tuple[Finding, ...] = ()
142
+ comparable = raw_comparable
143
+ if ruleset_manifest is not None:
144
+ for name, rule in dynamic_rules.items():
145
+ entry = ruleset_manifest.rules.get(name)
146
+ if entry is None:
147
+ raise ComparisonError(
148
+ f"dynamic result rule {name!r} is absent from ruleset manifest"
149
+ )
150
+ if entry.source_digest != rule.source_digest:
151
+ raise ComparisonError(
152
+ f"dynamic result rule {name!r} does not match its manifest source digest"
153
+ )
154
+
155
+ verified = {
156
+ name: rule
157
+ for name, rule in raw_comparable.items()
158
+ if (
159
+ name in ruleset_manifest.rules
160
+ and ruleset_manifest.rules[name].source_digest == rule.source_digest
161
+ )
162
+ }
163
+ unverified_names = raw_comparable.keys() - verified.keys()
164
+ ruleset_unverified = _sort_findings(
165
+ Finding(
166
+ status="ruleset-unverified",
167
+ rule=raw_comparable[name],
168
+ priority=0,
169
+ priority_label="informational",
170
+ reasons=(
171
+ "rule is absent from the supplied manifest"
172
+ if name not in ruleset_manifest.rules
173
+ else "static rule source differs from the supplied manifest",
174
+ "excluded from verified coverage",
175
+ ),
176
+ action="Regenerate both capa results and the manifest from the same ruleset revision.",
177
+ )
178
+ for name in unverified_names
179
+ )
180
+ comparable = verified
181
+ if ruleset_unverified:
182
+ warnings.append(
183
+ f"{len(ruleset_unverified)} comparable static rule(s) could not be verified against the supplied ruleset manifest and were excluded from coverage"
184
+ )
185
+
186
+ observed_names = comparable.keys() & dynamic_rules.keys()
187
+ unobserved_names = comparable.keys() - dynamic_rules.keys()
188
+ dynamic_only_names = dynamic_rules.keys() - static_rules.keys()
189
+
190
+ gate_signals = tuple(
191
+ sorted(
192
+ static_rules[name].name
193
+ for name in observed_names
194
+ if "anti-analysis" in static_rules[name].namespace.lower()
195
+ )
196
+ )
197
+ evasion_context = bool(gate_signals)
198
+
199
+ observed = _sort_findings(
200
+ _finding("observed", static_rules[name]) for name in observed_names
201
+ )
202
+ unobserved = _sort_findings(
203
+ _finding("unobserved", static_rules[name], evasion_context=evasion_context)
204
+ for name in unobserved_names
205
+ )
206
+ static_only = _sort_findings(
207
+ _finding("static-only", static_only_rules[name]) for name in static_only_rules
208
+ )
209
+ dynamic_only = _sort_findings(
210
+ _finding("dynamic-only", dynamic_rules[name]) for name in dynamic_only_names
211
+ )
212
+
213
+ source_drift = tuple(
214
+ sorted(
215
+ name
216
+ for name in static_rules.keys() & dynamic_rules.keys()
217
+ if static_rules[name].source_digest != dynamic_rules[name].source_digest
218
+ )
219
+ )
220
+ if source_drift:
221
+ warnings.append(
222
+ f"{len(source_drift)} shared rule(s) have different source text between reports; rule-set drift may affect results"
223
+ )
224
+
225
+ summary = ComparisonSummary(
226
+ static_rules=len(static_rules),
227
+ dynamic_rules=len(dynamic_rules),
228
+ comparable_static_rules=len(comparable),
229
+ observed_rules=len(observed),
230
+ unobserved_rules=len(unobserved),
231
+ static_only_rules=len(static_only),
232
+ dynamic_only_rules=len(dynamic_only),
233
+ ruleset_unverified_rules=len(ruleset_unverified),
234
+ )
235
+ metadata = {
236
+ "include_library_rules": include_library,
237
+ "sample_hashes_match": hashes_match,
238
+ "static_extractor": static.extractor,
239
+ "dynamic_extractor": dynamic.extractor,
240
+ }
241
+ if ruleset_manifest is not None:
242
+ metadata["ruleset_manifest"] = {
243
+ "source_name": ruleset_manifest.source_name,
244
+ "fingerprint": ruleset_manifest.fingerprint,
245
+ "path": ruleset_manifest.path.name,
246
+ "verified_comparable_rules": len(comparable),
247
+ "excluded_unverified_rules": len(ruleset_unverified),
248
+ }
249
+
250
+ return Comparison(
251
+ static=static,
252
+ dynamic=dynamic,
253
+ confidence=confidence,
254
+ summary=summary,
255
+ unobserved=unobserved,
256
+ observed=observed,
257
+ static_only=static_only,
258
+ dynamic_only=dynamic_only,
259
+ ruleset_unverified=ruleset_unverified,
260
+ evidence_hotspots=build_evidence_hotspots(unobserved, static.base_address),
261
+ gate_signals=gate_signals,
262
+ warnings=tuple(warnings),
263
+ source_drift=source_drift,
264
+ metadata=metadata,
265
+ )
@@ -0,0 +1,317 @@
1
+ :root {
2
+ color-scheme: light;
3
+ --surface: #fff;
4
+ --subtle: #f6f7f9;
5
+ --selected: #edf3fb;
6
+ --line: #d7dde5;
7
+ --line-strong: #9aafc8;
8
+ --ink: #1e2733;
9
+ --muted: #566170;
10
+ --accent: #1b579f;
11
+ --accent-hover: #134579;
12
+ --observed: #227342;
13
+ --warning: #8a590d;
14
+ --focus: #2867b5;
15
+ --radius: 4px;
16
+ font: 16px/1.5 "Segoe UI", -apple-system, BlinkMacSystemFont, sans-serif;
17
+ }
18
+ * { box-sizing: border-box; }
19
+ body { margin: 0; background: var(--surface); color: var(--ink); }
20
+ ::selection { background: #d8e7fa; color: var(--ink); }
21
+ button, input, select, textarea { font: inherit; color: inherit; }
22
+ button, summary { cursor: pointer; }
23
+ button { background: none; }
24
+ button:disabled { cursor: default; opacity: .5; }
25
+ button, a, input, select, summary { transition: background-color 150ms ease-out, color 150ms ease-out, border-color 150ms ease-out; }
26
+ a { color: var(--accent); text-underline-offset: 3px; }
27
+ a:hover { color: var(--accent-hover); }
28
+ :focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; }
29
+ input, textarea { caret-color: var(--accent); }
30
+ [hidden] { display: none !important; }
31
+ svg { flex-shrink: 0; }
32
+ .icon { width: 20px; height: 20px; vertical-align: middle; }
33
+ code, .score { font-family: ui-monospace, "Cascadia Code", "SFMono-Regular", Consolas, monospace; font-size: .93em; font-variant-numeric: tabular-nums; }
34
+ h1, h2, h3, p { margin: 0; }
35
+ h2 { font-size: 18px; font-weight: 600; }
36
+ h3 { font-size: 16px; font-weight: 600; margin-bottom: 8px; }
37
+ h3 .muted { font-weight: 400; }
38
+ .muted { color: var(--muted); }
39
+ .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
40
+ .print-only { display: none; }
41
+ .skip-link { position: fixed; top: -80px; left: 20px; z-index: 10; padding: 10px 16px; background: var(--surface); border: 1px solid var(--accent); }
42
+ .skip-link:focus { top: 10px; }
43
+ .appbar { min-height: 68px; padding: 14px 22px; display: flex; align-items: center; justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--line); }
44
+ .brand { display: flex; align-items: center; gap: 24px; min-width: 0; }
45
+ .wordmark { flex-shrink: 0; font-size: 30px; font-weight: 600; letter-spacing: -.03em; line-height: 1.2; }
46
+ .wordmark > span { color: var(--accent); }
47
+ .sample-name { font-size: 18px; padding-left: 22px; border-left: 1px solid var(--line); overflow-wrap: anywhere; }
48
+ .button { display: inline-flex; align-items: center; justify-content: center; gap: 9px; min-height: 38px; padding: 7px 13px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); font-size: 15px; }
49
+ .button:hover { background: var(--subtle); border-color: var(--line-strong); }
50
+ .appbar > .button { flex-shrink: 0; }
51
+ .sample-strip { min-height: 70px; padding: 18px 28px; border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; gap: 16px 28px; }
52
+ .sample-metadata, .coverage-summary { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; }
53
+ .sample-metadata { min-width: 0; }
54
+ .sample-metadata > span, .coverage-summary > span, .coverage-summary > a { padding-right: 18px; border-right: 1px solid var(--line); }
55
+ .sample-metadata > :last-child, .coverage-summary > :last-child { padding-right: 0; border: 0; }
56
+ .sample-metadata span { overflow-wrap: anywhere; }
57
+ .sample-metadata small { margin-right: 8px; color: var(--muted); font-size: 13px; }
58
+ .example-label { color: var(--accent); font-weight: 500; }
59
+ .coverage-summary { font-variant-numeric: tabular-nums; }
60
+ .coverage-summary strong { font-weight: 600; }
61
+ .coverage-summary > a { text-decoration: none; color: var(--ink); }
62
+ .coverage-summary > a:hover { color: var(--accent); text-decoration: underline; }
63
+ .coverage-summary .warning-link { color: var(--warning); display: inline-flex; gap: 6px; align-items: center; }
64
+ .workspace-toolbar { min-height: 70px; margin: 0 22px; display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--line); }
65
+ .workspace-toolbar nav { display: flex; align-self: stretch; align-items: stretch; gap: 16px; }
66
+ .workspace-toolbar nav a { display: flex; align-items: center; padding: 16px 10px; border-bottom: 2px solid transparent; text-decoration: none; color: var(--ink); }
67
+ .workspace-toolbar nav a:hover { background: var(--subtle); }
68
+ .workspace-toolbar nav a.active { color: var(--accent); border-color: var(--accent); font-weight: 500; }
69
+ .filters { display: flex; align-items: center; gap: 14px; }
70
+ .search-field { display: flex; align-items: center; gap: 10px; width: 320px; min-height: 40px; border: 1px solid var(--line); border-radius: var(--radius); padding: 0 12px; }
71
+ .search-field > .icon { color: var(--muted); width: 18px; height: 18px; }
72
+ .search-field:focus-within { outline: 2px solid var(--focus); outline-offset: 2px; }
73
+ .search-field input { width: 100%; min-width: 0; padding: 8px 0; border: 0; background: transparent; outline: none; }
74
+ input::placeholder { color: var(--muted); opacity: 1; }
75
+ .state-filter select { min-height: 40px; width: 180px; padding: 7px 32px 7px 12px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); }
76
+ .matrix-section { margin: 0 22px; scroll-margin-top: 18px; }
77
+ .matrix-section:focus { outline: none; }
78
+ .groupbar { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; min-height: 74px; padding: 16px 0; }
79
+ .group-button { display: inline-flex; align-items: center; gap: 20px; min-height: 39px; padding: 7px 16px; border: 1px solid var(--line); border-radius: var(--radius); font-size: 15px; }
80
+ .group-button span { font-variant-numeric: tabular-nums; }
81
+ .group-button:hover { background: var(--subtle); border-color: var(--line-strong); }
82
+ .group-button[aria-pressed="true"] { color: #fff; background: var(--accent); border-color: var(--accent); }
83
+ .group-description { color: var(--muted); font-size: 14px; padding: 0 0 14px; }
84
+ .matrix-scroll { overflow-x: auto; border: 1px solid var(--line); border-radius: var(--radius); }
85
+ .matrix { width: 100%; border-collapse: collapse; text-align: left; table-layout: auto; }
86
+ .matrix th, .matrix td { padding: 13px 22px; border-right: 1px solid var(--line); overflow-wrap: anywhere; }
87
+ .matrix tr > :last-child { border-right: 0; }
88
+ .matrix > thead th { font-weight: 500; background: var(--subtle); height: 75px; padding-top: 5px; padding-bottom: 5px; vertical-align: middle; }
89
+ .matrix > thead .run-heading { min-width: 118px; text-align: center; max-width: 220px; }
90
+ .run-heading > span { display: block; font-size: 14px; font-weight: 400; }
91
+ .run-heading, .run-cell { font-variant-numeric: tabular-nums; }
92
+ .matrix .expand-cell { width: 64px; min-width: 56px; padding: 7px 8px; text-align: center; }
93
+ .name-heading, .name-cell { min-width: 220px; }
94
+ .namespace-heading, .namespace-cell { min-width: 190px; }
95
+ .state-heading, .state-cell { min-width: 134px; }
96
+ .attack-heading, .attack-cell { min-width: 106px; }
97
+ .identifier-token { display: inline-block; white-space: nowrap; }
98
+ .priority-heading, .priority-cell { min-width: 100px; }
99
+ .capability-row { height: 54px; border-top: 1px solid var(--line); cursor: pointer; }
100
+ .capability-row th { font-weight: 400; }
101
+ .capability-row:hover { background: var(--subtle); }
102
+ .capability-row.selected { background: var(--selected); border-top-color: var(--line-strong); }
103
+ .capability-row.selected > * { border-right-color: #ccd9e9; }
104
+ .capability-row.selected .name-cell { font-weight: 500; }
105
+ .row-toggle { display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 38px; border: 0; border-radius: var(--radius); }
106
+ .row-toggle:hover { background: #dce7f6; }
107
+ .row-toggle .icon { width: 18px; height: 18px; transition: transform 150ms ease-out; }
108
+ .row-toggle[aria-expanded="true"] .icon { transform: rotate(90deg); }
109
+ .run-cell { text-align: center; }
110
+ .observation { display: inline-flex; align-items: center; }
111
+ .observation .icon { width: 24px; height: 24px; }
112
+ .observed { color: var(--observed); }
113
+ .missing, .neutral { color: var(--muted); }
114
+ .mixed { color: var(--warning); }
115
+ .priority-cell { text-transform: lowercase; white-space: nowrap; }
116
+ .priority-cell .score { margin-left: 8px; }
117
+ .mobile-namespace { display: none; }
118
+ .details-row > td { padding: 0; background: var(--subtle); border-bottom: 1px solid var(--line-strong); }
119
+ .finding-detail { display: grid; grid-template-columns: minmax(220px,1.3fr) minmax(205px,.85fr) minmax(240px,1.45fr); gap: 0; min-height: 212px; padding: 20px 30px 20px 84px; }
120
+ .finding-detail > div { min-width: 0; }
121
+ .evidence-column { padding-right: 46px; }
122
+ .rule-column { padding: 0 32px; border-left: 1px solid var(--line); }
123
+ .action-column { padding-left: 32px; border-left: 1px solid var(--line); }
124
+ .action-column > p { max-width: 70ch; line-height: 1.6; }
125
+ .action-column > p + p { margin-top: 12px; }
126
+ .evidence-source + .evidence-source { margin-top: 22px; }
127
+ .evidence-table { width: 100%; border-collapse: collapse; font-size: 16px; text-align: left; }
128
+ .evidence-table th, .evidence-table td { height: auto; padding: 3px 0; border: 0; border-bottom: 1px solid var(--line); font-weight: 400; background: transparent; }
129
+ .evidence-table th { color: var(--muted); }
130
+ .evidence-table td:last-child { width: 30px; }
131
+ .finding-detail code { font-size: 1em; }
132
+ .evidence-table code { overflow-wrap: anywhere; }
133
+ .icon-button { display: inline-flex; align-items: center; justify-content: center; vertical-align: middle; min-width: 28px; height: 28px; border: 0; border-radius: var(--radius); color: var(--muted); }
134
+ .icon-button .icon { width: 17px; height: 17px; }
135
+ .icon-button:hover { color: var(--accent); background: #e4ebf4; }
136
+ .image-base { display: flex; align-items: center; gap: 16px; margin-top: 9px; font-size: 16px; flex-wrap: wrap; }
137
+ .image-base .copy-button { margin-left: auto; }
138
+ .evidence-note { font-size: 13px; margin-top: 12px; }
139
+ .key-values { margin: 0; }
140
+ .key-values > div { display: grid; grid-template-columns: minmax(95px,.9fr) minmax(0,1.2fr); gap: 12px; padding: 3px 0; }
141
+ .key-values dt, .key-values dd { margin: 0; overflow-wrap: anywhere; }
142
+ .rule-column .key-values > div { grid-template-columns: minmax(7.25rem,max-content) minmax(0,1fr); }
143
+ .identifiers { border-bottom: 1px solid var(--line); padding-bottom: 6px; }
144
+ .scopes { padding-top: 6px; font-size: 16px; }
145
+ .reason-details { margin-top: 10px; font-size: 13px; }
146
+ .reason-details summary { color: var(--muted); width: fit-content; }
147
+ .reason-details p, .reason-details ul { margin-top: 10px; }
148
+ .reason-details ul { padding-left: 18px; }
149
+ .reason-details li + li { margin-top: 5px; }
150
+ .matrix-footer { display: flex; align-items: center; justify-content: space-between; gap: 20px; min-height: 62px; padding: 14px 0; font-size: 13px; }
151
+ .legend { display: flex; align-items: center; gap: 18px; margin-left: auto; }
152
+ .legend > span { display: flex; align-items: center; gap: 7px; white-space: nowrap; }
153
+ .legend > span > span { color: var(--ink); }
154
+ .boundary { color: var(--muted); padding-left: 18px; border-left: 1px solid var(--line); }
155
+ .report-sections { margin: 0 22px 26px; border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
156
+ .report-section + .report-section { border-top: 1px solid var(--line); }
157
+ .report-section { scroll-margin-top: 20px; }
158
+ .report-section > summary { display: flex; align-items: center; gap: 16px; padding: 13px 20px; list-style: none; min-height: 49px; }
159
+ .report-section > summary::-webkit-details-marker { display: none; }
160
+ .report-section > summary .icon { width: 17px; height: 17px; }
161
+ .report-section[open] > summary .icon { transform: rotate(90deg); }
162
+ .report-section > summary:hover { background: var(--subtle); }
163
+ .report-section[open] > summary { background: var(--subtle); font-weight: 500; border-bottom: 1px solid var(--line); }
164
+ .section-content { padding: 22px 26px; }
165
+ .section-intro { color: var(--muted); max-width: 80ch; margin-bottom: 20px; }
166
+ .section-content p + p { margin-top: 12px; }
167
+ .section-content ul { margin: 0 0 14px; padding-left: 20px; }
168
+ .horizontal-scroll { overflow-x: auto; }
169
+ .data-table { width: 100%; border-collapse: collapse; text-align: left; font-size: 14px; }
170
+ .data-table th, .data-table td { padding: 12px 14px; border-bottom: 1px solid var(--line); vertical-align: top; }
171
+ .data-table thead th { font-weight: 500; color: var(--muted); }
172
+ .data-table tbody th { font-weight: 500; }
173
+ .data-table .icon-button { margin-left: 9px; }
174
+ .data-table code { overflow-wrap: anywhere; }
175
+ .text-button { border: 0; text-align: left; color: var(--accent); padding: 4px 0; text-underline-offset: 3px; }
176
+ .text-button:hover { text-decoration: underline; }
177
+ .hotspot-links > .text-button { display: block; }
178
+ .warning-list { color: var(--warning); line-height: 1.65; }
179
+ .warning-list li + li { margin-top: 7px; }
180
+ .input-documents { display: grid; grid-template-columns: repeat(auto-fit,minmax(min(100%,330px),1fr)); gap: 32px; margin-bottom: 26px; }
181
+ .input-document { min-width: 0; }
182
+ .input-document > h3 { margin-bottom: 14px; }
183
+ .input-document .key-values { font-size: 13px; }
184
+ .hash-line { display: flex; align-items: start; flex-wrap: wrap; gap: 9px; font-size: 13px; margin-bottom: 12px; }
185
+ .hash-line > code { overflow-wrap: anywhere; flex-basis: 100%; order: 3; }
186
+ .empty-state { text-align: center; padding: 48px 20px; border: 1px solid var(--line); border-top: 0; }
187
+ .empty-state p { margin: 10px auto 20px; color: var(--muted); max-width: 60ch; }
188
+ .no-script { padding: 15px 0; }
189
+ .toast { position: fixed; bottom: 22px; right: 22px; max-width: min(360px,calc(100% - 44px)); padding: 12px 18px; border: 1px solid var(--line-strong); border-radius: var(--radius); background: var(--surface); color: var(--ink); z-index: 5; }
190
+ .copy-dialog { border: 1px solid var(--line); border-radius: 6px; background: var(--surface); color: var(--ink); max-width: 600px; width: calc(100% - 40px); padding: 24px; }
191
+ .copy-dialog::backdrop { background: rgb(20 30 45 / 28%); }
192
+ .dialog-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
193
+ .copy-dialog p { margin-bottom: 16px; color: var(--muted); }
194
+ .copy-dialog textarea { width: 100%; min-height: 120px; resize: vertical; padding: 12px; border: 1px solid var(--line); background: var(--subtle); border-radius: var(--radius); }
195
+
196
+ @media (max-width: 1250px) {
197
+ .sample-strip { flex-wrap: wrap; gap: 14px; }
198
+ .coverage-summary { font-size: 14px; }
199
+ .namespace-cell, .namespace-heading { display: none; }
200
+ .mobile-namespace { display: block; color: var(--muted); font-size: 12px; font-weight: 400; margin-top: 4px; overflow-wrap: anywhere; }
201
+ .matrix th, .matrix td { padding-left: 16px; padding-right: 16px; }
202
+ .matrix .expand-cell { width: 52px; min-width: 46px; padding-left: 6px; padding-right: 6px; }
203
+ .details-row > td { padding: 0; }
204
+ .finding-detail { padding-left: 56px; padding-right: 22px; grid-template-columns: minmax(190px,1fr) minmax(170px,.85fr) minmax(210px,1.3fr); }
205
+ .evidence-column { padding-right: 24px; }
206
+ .rule-column { padding: 0 24px; }
207
+ .action-column { padding-left: 24px; }
208
+ .search-field { width: 260px; }
209
+ .workspace-toolbar nav { gap: 8px; }
210
+ .matrix-footer { flex-wrap: wrap; gap: 10px 20px; }
211
+ }
212
+ @media (max-width: 960px) {
213
+ .workspace-toolbar { flex-wrap: wrap; padding-bottom: 14px; gap: 10px; }
214
+ .workspace-toolbar nav { width: 100%; min-height: 56px; }
215
+ .filters { width: 100%; }
216
+ .search-field { width: auto; flex: 1; }
217
+ .matrix th, .matrix td { padding-left: 12px; padding-right: 12px; }
218
+ .details-row > td { padding: 0; }
219
+ .matrix > thead .run-heading { min-width: 100px; }
220
+ .matrix .name-heading, .matrix .name-cell { min-width: 210px; }
221
+ .finding-detail { grid-template-columns: 1.15fr 1fr; gap: 22px 0; padding: 24px; }
222
+ .action-column { border: 0; border-top: 1px solid var(--line); padding: 18px 0 0; grid-column: 1 / -1; }
223
+ .boundary { padding-left: 0; border: 0; }
224
+ .legend { margin-left: 0; }
225
+ }
226
+ @media (max-width: 600px) {
227
+ :root { font-size: 14px; }
228
+ h3 { font-size: 15px; margin-bottom: 12px; }
229
+ .finding-detail code { font-size: .93em; }
230
+ .identifier-token { white-space: normal; overflow-wrap: anywhere; }
231
+ .image-base { font-size: 14px; }
232
+ .identifiers { padding-bottom: 10px; }
233
+ .scopes { padding-top: 9px; font-size: 14px; }
234
+ .reason-details { margin-top: 14px; }
235
+ .appbar { min-height: 64px; padding: 13px 16px; gap: 12px; }
236
+ .wordmark { font-size: 24px; }
237
+ .brand { gap: 10px; }
238
+ .sample-name { display: none; }
239
+ .button { font-size: 13px; min-height: 40px; }
240
+ .sample-strip { padding: 16px; }
241
+ .sample-metadata, .coverage-summary { gap: 8px 14px; font-size: 13px; }
242
+ .sample-metadata > span, .coverage-summary > span, .coverage-summary > a { padding-right: 12px; }
243
+ .sample-metadata small { margin-right: 4px; }
244
+ .workspace-toolbar { margin: 0 16px; }
245
+ .workspace-toolbar nav { justify-content: space-between; gap: 0; min-height: 54px; }
246
+ .workspace-toolbar nav a { padding: 12px 5px; font-size: 13px; }
247
+ .filters { gap: 8px; }
248
+ .search-field { min-height: 42px; padding: 0 9px; gap: 7px; }
249
+ .search-field input { font-size: 14px; }
250
+ .state-filter select { width: 133px; padding-right: 20px; font-size: 13px; min-height: 42px; }
251
+ .matrix-section { margin: 0 16px; }
252
+ .groupbar { gap: 8px; min-height: 66px; }
253
+ .group-button { padding: 8px 10px; gap: 10px; font-size: 13px; min-height: 42px; }
254
+ .matrix-scroll { border: 0; border-bottom: 1px solid var(--line); overflow: visible; }
255
+ .matrix { display: block; }
256
+ .matrix > thead { display: none; }
257
+ .matrix-group { display: block; border: 1px solid var(--line); border-bottom: 0; }
258
+ .capability-row { display: grid; grid-template-columns: 42px minmax(0,1fr) minmax(0,1fr); height: auto; padding: 8px 10px 12px 0; gap: 6px 0; border: 0; }
259
+ .matrix .capability-row > * { padding: 4px 8px; min-width: 0; border: 0; }
260
+ .matrix .capability-row .expand-cell { grid-column: 1; grid-row: 1; padding: 0; min-width: 42px; width: auto; }
261
+ .matrix .name-cell { grid-column: 2 / -1; font-size: 14px; min-width: 0; align-self: center; text-align: left; }
262
+ .matrix .namespace-cell { display: none; }
263
+ .state-cell { grid-column: 2 / -1; font-size: 12px; color: var(--muted); }
264
+ .run-cell, .attack-cell, .priority-cell { display: flex; align-items: center; justify-content: space-between; gap: 12px; font-size: 12px; text-align: left; white-space: normal; }
265
+ .run-cell { grid-column: 2 / -1; }
266
+ .run-cell::before, .attack-cell::before, .priority-cell::before { content: attr(data-label); color: var(--muted); overflow-wrap: anywhere; }
267
+ .observation::after { margin-left: 7px; content: ""; }
268
+ .run-cell.observed .observation::after { content: "Observed"; }
269
+ .run-cell.missing .observation::after { content: "Missing"; }
270
+ .run-cell.neutral .observation::after { content: "Not comparable"; }
271
+ .observation .icon { width: 18px; height: 18px; }
272
+ .attack-cell { grid-column: 2; flex-wrap: wrap; justify-content: flex-start; gap: 4px 8px; }
273
+ .priority-cell { grid-column: 3; justify-content: flex-end; gap: 8px; }
274
+ .priority-cell .score { margin: 0; }
275
+ .details-row, .details-row > td { display: block; width: 100%; }
276
+ .finding-detail { display: block; min-height: 0; padding: 20px; border-top: 1px solid var(--line-strong); }
277
+ .evidence-column, .rule-column, .action-column { padding: 0; border: 0; }
278
+ .rule-column, .action-column { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--line); }
279
+ .evidence-table { font-size: 13px; }
280
+ .evidence-table th, .evidence-table td { padding: 8px 0; }
281
+ .icon-button { min-width: 32px; height: 32px; }
282
+ .key-values > div { grid-template-columns: 1fr 1fr; padding: 7px 0; }
283
+ .rule-column .key-values > div { grid-template-columns: 1fr 1fr; }
284
+ .matrix-footer { padding: 16px 0; font-size: 12px; gap: 12px; }
285
+ .legend { width: 100%; justify-content: space-between; gap: 8px; }
286
+ .legend > span { gap: 6px; }
287
+ .legend .icon { width: 18px; height: 18px; }
288
+ .boundary { line-height: 1.5; }
289
+ .report-sections { margin: 0 16px 24px; }
290
+ .report-section > summary { min-height: 50px; padding: 13px 16px; gap: 12px; }
291
+ .section-content { padding: 18px 16px; }
292
+ .data-table { min-width: 520px; font-size: 13px; }
293
+ .input-document .key-values > div { display: block; }
294
+ .input-document dt { color: var(--muted); margin-bottom: 4px; }
295
+ .input-documents { gap: 24px; }
296
+ }
297
+ @media (prefers-reduced-motion: reduce) {
298
+ *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; animation: none !important; }
299
+ }
300
+ @media print {
301
+ :root { font-size: 10pt; }
302
+ .js-only, .row-toggle, .filters, .workspace-toolbar, .groupbar, .skip-link, .copy-dialog, .toast { display: none !important; }
303
+ .print-only { display: inline; }
304
+ .matrix-group[hidden] { display: table-row-group !important; }
305
+ .details-row[hidden] { display: table-row !important; }
306
+ .matrix-scroll, .horizontal-scroll { overflow: visible; border-radius: 0; }
307
+ .matrix { min-width: 0; }
308
+ .matrix th, .matrix td { min-width: 0; padding: 7px; }
309
+ .namespace-cell, .namespace-heading { display: none; }
310
+ .mobile-namespace { display: block; }
311
+ .finding-detail { min-height: 0; padding: 16px; font-size: 9pt; }
312
+ .matrix-group, .report-section { break-inside: avoid; }
313
+ .empty-state { display: none !important; }
314
+ .appbar { padding: 0 0 14px; }
315
+ .sample-strip { padding: 14px 0; }
316
+ .matrix-section, .report-sections { margin-left: 0; margin-right: 0; }
317
+ }