File size: 17,165 Bytes
7f913f4
 
 
 
1f70c12
7f913f4
 
 
bc364ef
7f913f4
 
 
 
 
 
 
 
bc364ef
11fedb8
1f70c12
 
 
11fedb8
 
 
7f913f4
 
 
11fedb8
 
 
7f913f4
 
 
9f288a7
 
 
bc364ef
 
2c39b7a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9f288a7
2c39b7a
 
 
 
 
 
9f288a7
 
2c39b7a
 
bc364ef
2c39b7a
 
 
 
 
9f288a7
 
 
 
2c39b7a
 
 
 
 
9f288a7
 
2c39b7a
 
b8ba99c
 
 
 
 
 
 
 
 
 
 
9f288a7
 
2c39b7a
 
 
 
9f288a7
 
 
 
 
 
2c39b7a
 
 
9f288a7
 
2c39b7a
 
9f288a7
 
2c39b7a
 
 
 
 
9f288a7
 
 
2c39b7a
 
 
9f288a7
 
2c39b7a
 
9f288a7
2c39b7a
 
 
 
9f288a7
 
 
 
2c39b7a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1f70c12
 
 
 
 
 
 
 
 
 
 
7f913f4
 
 
 
 
 
8b350c9
2c39b7a
11fedb8
2c39b7a
 
 
 
 
 
bc364ef
 
11fedb8
 
2c39b7a
 
 
 
 
 
 
 
bc364ef
1f70c12
2c39b7a
bc364ef
7f913f4
 
11fedb8
 
7f913f4
971e330
67fadc5
fc6a75f
67fadc5
 
 
fc6a75f
9f288a7
 
 
 
 
 
 
 
2c39b7a
 
 
 
 
 
11fedb8
2c39b7a
 
 
 
 
11fedb8
2c39b7a
 
11fedb8
 
fb8faad
11fedb8
 
67fadc5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fb8faad
971e330
 
dba17cc
11fedb8
7f913f4
 
 
 
 
 
 
 
2c39b7a
11fedb8
2c39b7a
 
 
 
 
 
 
11fedb8
2c39b7a
11fedb8
 
 
 
2c39b7a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11fedb8
2c39b7a
7f913f4
11fedb8
 
7f913f4
2c39b7a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bc364ef
 
2c39b7a
11fedb8
2c39b7a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7f913f4
 
971e330
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
from __future__ import annotations

import html
import json
import tempfile

import gradio as gr

from scanner import ScanError, ScanResult, result_json, scan_repository


def _summary_markdown(result: ScanResult) -> str:
    counts = result.counts()
    badges = " · ".join(
        f"**{name.title()}: {counts.get(name, 0)}**"
        for name in ("critical", "high", "medium", "low", "info")
    )
    artifacts = result.artifacts
    priority = "No unresolved non-informational finding was detected."
    for finding in result.finding_groups():
        if finding["status"] != "unresolved" or finding["severity"] == "info":
            continue
        priority = f"**{finding['severity'].upper()} · {finding['rule_id']}** — {html.escape(finding['title'])}"
        break
    return f"""### Scan complete: `{html.escape(result.target)}`

{badges}

- **Type / revision:** {result.repo_type.title()} · `{result.revision[:12]}`
- **Coverage / OSV:** `{html.escape(str(artifacts.get('static_coverage', 'unknown')).upper())}` {artifacts.get('inspected_text_files', len(result.inspected_files))}/{artifacts.get('eligible_text_files', len(result.inspected_files))} · `{html.escape(str(result.osv_summary.get('status', 'not checked')).upper())}` {result.osv_summary.get('queried_packages', 0)}/{result.osv_summary.get('exact_packages', 0)}
- **Top priority:** {priority}
"""


def _safe(value: object, fallback: str = "not stated") -> str:
    text = str(value) if value not in (None, "") else fallback
    return html.escape(text)


def _overview_markdown(result: ScanResult) -> str:
    metadata = result.metadata_summary
    artifacts = result.artifacts
    dependencies = ", ".join(f"`{_safe(item)}`" for item in result.dependencies) or "None declared in Hub metadata"
    notes = "\n".join(f"- {_safe(note)}" for note in result.notes) or "- No additional note was produced."
    exact = sum(1 for item in result.package_inventory if item.get("source_type") == "exact")
    unresolved = sum(1 for item in result.package_inventory if item.get("source_type") != "exact")
    return f"""### Repository details

- **License / task / library:** `{_safe(metadata.get('license'), 'undeclared')}` / `{_safe(metadata.get('pipeline_tag'), 'undeclared')}` / `{_safe(metadata.get('library'), 'undeclared')}`
- **Architecture:** `{_safe(metadata.get('architecture'), 'not identified')}`
- **Text/config files inspected:** {len(result.inspected_files)}
- **Candidate files skipped:** {result.skipped_files}
- **Published files:** {artifacts.get('repository_file_count', 0)} totaling {artifacts.get('repository_size', '0 B')}
- **Weight artifacts:** {artifacts.get('weight_file_count', 0)} totaling {artifacts.get('weight_size', '0 B')} ({_safe(artifacts.get('weight_formats') or 'none')})
- **Safetensors index:** {artifacts.get('index_referenced_shards', 0)} referenced shards; {artifacts.get('index_declared_tensor_size', '0 B')} declared tensor data
- **Declared dependencies:** {dependencies}
- **Package inventory:** {len(result.package_inventory)} components ({exact} exact; {unresolved} unresolved/direct)

### Important limitations

{notes}
"""


def _coverage_items(result: ScanResult) -> list[dict[str, str]]:
    items = []
    for item in result.checks:
        status = item.status.upper().replace("_", " ")
        items.append({
            "label": f"{status} · {item.category}",
            "detail": f"### {_safe(item.category)}\n\n**Status:** `{_safe(status)}`\n\n{_safe(item.detail)}",
        })
    return items or [{"label": "No coverage records", "detail": "No coverage record was produced."}]


def _dependency_items(result: ScanResult) -> list[dict[str, str]]:
    items = []
    for item in result.dependency_details:
        relations = ", ".join(item.get("relation") or [])
        items.append({
            "label": f"{relations or 'declared'} · {item.get('repository', 'unknown')}",
            "detail": f"### {_safe(item.get('repository'))}\n\n"
            f"- **Relationship:** {_safe(relations)}\n"
            f"- **Depth:** {_safe(item.get('depth'))}\n"
            f"- **License:** {_safe(item.get('license'), 'undeclared')}\n"
            f"- **Status:** {_safe(item.get('status'), 'unknown')}\n"
            f"- **Revision:** `{_safe(item.get('revision'))}`\n"
            f"- **Gated:** {_safe(item.get('gated', False))}",
        })
    if not items:
        items = [{"label": f"declared · {item}", "detail": f"### {_safe(item)}\n\nDeclared dependency; details were not checked."} for item in result.dependencies]
    return items or [{"label": "No upstream dependencies", "detail": "No upstream repository dependency was declared."}]


def _finding_items(result: ScanResult) -> list[dict[str, str]]:
    items = []
    for finding in result.finding_groups():
        occurrences = finding["occurrences"]
        locations = []
        evidence = ""
        for occurrence in occurrences[:3]:
            location = occurrence.get("path") or "Repository metadata"
            if occurrence.get("line"):
                location += f":{occurrence['line']}"
            locations.append(location)
            if not evidence and occurrence.get("evidence"):
                evidence = occurrence["evidence"]
        location_text = ", ".join(locations) + (" …" if len(occurrences) > 3 else "")
        evidence_line = f"\n- **Evidence:** `{_safe(evidence)}`" if evidence else ""
        label = f"{finding['severity'].upper()} · {finding['rule_id']} · {finding['title']}"
        items.append({
            "label": label,
            "detail": f"### {_safe(label)}\n\n"
            f"- **Status:** {_safe(finding['status'].upper())}\n"
            f"- **Occurrences:** {len(occurrences)}\n"
            f"- **Locations:** {_safe(location_text)}"
            f"{evidence_line}\n"
            f"- **Confidence:** {_safe(finding['confidence'])}\n\n"
            f"**Why it matters**\n\n{_safe(finding['detail'])}\n\n"
            f"**Remediation**\n\n{_safe(finding['remediation'])}",
        })
    return items or [{"label": "No findings", "detail": "No evidence-linked finding was produced."}]


def _package_items(result: ScanResult) -> list[dict[str, str]]:
    items = []
    for item in result.package_inventory:
        manifests = ", ".join(sorted({occurrence.get("path", "") for occurrence in item.get("occurrences", [])}))
        version = item.get("version") or "unresolved"
        label = f"{item.get('name', 'unknown')} · {version}"
        items.append({
            "label": label,
            "detail": f"### {_safe(label)}\n\n"
            f"- **Source type:** {_safe(item.get('source_type'))}\n"
            f"- **Constraint:** `{_safe(item.get('constraint'))}`\n"
            f"- **PURL:** `{_safe(item.get('purl'))}`\n"
            f"- **Manifest:** {_safe(manifests)}",
        })
    return items or [{"label": "No packages", "detail": "No supported package declaration was found."}]


def _vulnerability_items(result: ScanResult) -> list[dict[str, str]]:
    items = []
    for item in result.vulnerabilities:
        label = f"{item.get('severity', '').upper()} · {item.get('id', 'unknown')} · {item.get('package', 'unknown')}"
        items.append({
            "label": label,
            "detail": f"### {_safe(label)}\n\n"
            f"{_safe(item.get('summary'))}\n\n"
            f"- **Package:** {_safe(item.get('package'))} `{_safe(item.get('version'))}`\n"
            f"- **Aliases:** {_safe(', '.join(item.get('aliases') or []))}\n"
            f"- **Fixed versions:** {_safe(', '.join(item.get('fixed_versions') or []))}\n"
            f"- **Advisory:** {_safe(item.get('url'))}",
        })
    return items or [{"label": "No known matches", "detail": "No known OSV match was returned for the exactly pinned packages checked."}]


def _selector_update(items: list[dict[str, str]], label: str):
    choices = [(item["label"], str(index)) for index, item in enumerate(items)]
    return gr.Dropdown(choices=choices, value="0", label=f"{label} ({len(items)})")


def _select_detail(selection: str | None, state: dict[str, list[str]] | None, section: str) -> str:
    details = (state or {}).get(section) or []
    try:
        index = int(selection or 0)
    except (TypeError, ValueError):
        index = 0
    if not details:
        return "Run a scan to populate this section."
    return details[index] if 0 <= index < len(details) else details[0]


def _write_sbom(result: ScanResult) -> str:
    with tempfile.NamedTemporaryFile(
        mode="w", encoding="utf-8", prefix="modelsentry-", suffix=".cdx.json", delete=False,
    ) as handle:
        json.dump(result.sbom, handle, indent=2, ensure_ascii=False)
        handle.write("\n")
        return handle.name


def run_scan(target: str):
    try:
        result = scan_repository(target)
    except ScanError as exc:
        raise gr.Error(str(exc)) from exc
    except Exception as exc:
        raise gr.Error("The public repository could not be scanned because of an unexpected service error. Please try again.") from exc
    sections = {
        "overview": [{"label": "Repository profile", "detail": _overview_markdown(result)}] + _coverage_items(result),
        "dependencies": _dependency_items(result),
        "packages": _package_items(result),
        "vulnerabilities": _vulnerability_items(result),
        "findings": _finding_items(result),
    }
    view_state = {name: [item["detail"] for item in items] for name, items in sections.items()}
    return (
        _summary_markdown(result),
        _selector_update(sections["overview"], "Overview topic"),
        sections["overview"][0]["detail"],
        _selector_update(sections["dependencies"], "Upstream repository"),
        sections["dependencies"][0]["detail"],
        _selector_update(sections["packages"], "Package"),
        sections["packages"][0]["detail"],
        _selector_update(sections["vulnerabilities"], "Known vulnerability"),
        sections["vulnerabilities"][0]["detail"],
        _selector_update(sections["findings"], "Finding"),
        sections["findings"][0]["detail"],
        result_json(result),
        _write_sbom(result),
        view_state,
    )


DESCRIPTION = """Inspect a public Hugging Face Model or Space at an immutable revision. ModelSentry never executes
repository code or downloads model weights; its findings are bounded static evidence, not a safety certification."""

APP_CSS = """
html,
body {
    height: 100%;
    overflow: hidden !important;
    margin: 0;
}
.audit-cards,
.audit-cards p,
.audit-cards li,
.audit-cards code {
    overflow-wrap: anywhere;
    word-break: break-word;
    white-space: normal;
}
.scan-summary {
    border: 1px solid var(--border-color-primary);
    border-left: 0.35rem solid var(--primary-500);
    border-radius: var(--radius-lg);
    padding: 0.25rem 1rem;
    background: var(--background-fill-secondary);
    min-height: 12rem;
}
.detail-card {
    border: 1px solid var(--border-color-primary);
    border-radius: var(--radius-lg);
    padding: 0.75rem 1rem;
    min-height: 22rem;
    background: var(--background-fill-primary);
}
.compact-note {
    font-size: 0.85rem;
    color: var(--body-text-color-subdued);
}
.gradio-container {
    width: 100%;
    max-width: none !important;
    height: 100vh !important;
    overflow-y: scroll !important;
    overflow-x: hidden !important;
    scrollbar-gutter: stable;
    scrollbar-width: auto;
    scrollbar-color: rgba(100, 116, 139, 0.9) rgba(148, 163, 184, 0.22);
    box-sizing: border-box;
    padding: 1rem max(1rem, calc((100vw - 1200px) / 2)) 2rem !important;
}
.gradio-container::-webkit-scrollbar {
    width: 14px;
}
.gradio-container::-webkit-scrollbar-track {
    background: rgba(148, 163, 184, 0.22);
}
.gradio-container::-webkit-scrollbar-thumb {
    background: rgba(100, 116, 139, 0.9);
    border: 3px solid transparent;
    border-radius: 999px;
    background-clip: padding-box;
}
.gradio-container::-webkit-scrollbar-thumb:hover {
    background: var(--primary-500, #6366f1);
    background-clip: padding-box;
}
"""

with gr.Blocks(title="ModelSentry", delete_cache=(600, 1800)) as demo:
    gr.Markdown("# 🛡️ ModelSentry · Hugging Face repository auditor")
    gr.Markdown(DESCRIPTION)
    with gr.Row():
        target = gr.Textbox(
            label="Public Model or Space",
            placeholder="https://huggingface.co/spaces/owner/name or model:owner/name",
            scale=5,
        )
        scan = gr.Button("Scan immutable revision", variant="primary", scale=1)
    summary = gr.Markdown(
        "### Ready to scan\n\nEnter `model:owner/name`, `space:owner/name`, or a public Hugging Face URL.\n\nThe result summary will stay in this fixed panel.",
        elem_classes=["scan-summary", "audit-cards"],
    )
    view_state = gr.State({})
    with gr.Tabs():
        with gr.Tab("Overview"):
            with gr.Row(equal_height=False):
                with gr.Column(scale=1, min_width=280):
                    coverage_selector = gr.Dropdown(label="Overview topic", choices=[], interactive=True)
                with gr.Column(scale=2, min_width=360):
                    coverage_detail = gr.Markdown(
                        "### Overview inspector\n\nRun a scan, then choose the repository profile or a coverage area.",
                        elem_classes=["detail-card", "audit-cards"],
                    )
        with gr.Tab("Findings"):
            with gr.Row(equal_height=False):
                with gr.Column(scale=1, min_width=280):
                    finding_selector = gr.Dropdown(label="Finding", choices=[], interactive=True)
                with gr.Column(scale=2, min_width=360):
                    finding_detail = gr.Markdown("Select a finding.", elem_classes=["detail-card", "audit-cards"])
        with gr.Tab("Provenance"):
            with gr.Row(equal_height=False):
                with gr.Column(scale=1, min_width=280):
                    dependency_selector = gr.Dropdown(label="Upstream repository", choices=[], interactive=True)
                with gr.Column(scale=2, min_width=360):
                    dependency_detail = gr.Markdown("Select an upstream repository.", elem_classes=["detail-card", "audit-cards"])
        with gr.Tab("Packages"):
            with gr.Row(equal_height=False):
                with gr.Column(scale=1, min_width=280):
                    package_selector = gr.Dropdown(label="Package", choices=[], interactive=True)
                with gr.Column(scale=2, min_width=360):
                    package_detail = gr.Markdown("Select a package.", elem_classes=["detail-card", "audit-cards"])
        with gr.Tab("Vulnerabilities"):
            with gr.Row(equal_height=False):
                with gr.Column(scale=1, min_width=280):
                    vulnerability_selector = gr.Dropdown(label="Known vulnerability", choices=[], interactive=True)
                with gr.Column(scale=2, min_width=360):
                    vulnerability_detail = gr.Markdown("Select a vulnerability.", elem_classes=["detail-card", "audit-cards"])
        with gr.Tab("JSON / SBOM"):
            report = gr.Code(language="json", label="modelsentry.scan.v4 JSON", lines=18)
            sbom = gr.File(label="CycloneDX 1.6 SBOM", interactive=False)
    gr.Markdown(
        "Static evidence requires human review. SBOM download files are eligible for automatic cleanup within 30 minutes.",
        elem_classes=["compact-note"],
    )
    outputs = [
        summary,
        coverage_selector,
        coverage_detail,
        dependency_selector,
        dependency_detail,
        package_selector,
        package_detail,
        vulnerability_selector,
        vulnerability_detail,
        finding_selector,
        finding_detail,
        report,
        sbom,
        view_state,
    ]
    scan.click(run_scan, inputs=target, outputs=outputs)
    target.submit(run_scan, inputs=target, outputs=outputs)
    coverage_selector.change(
        lambda selection, state: _select_detail(selection, state, "overview"),
        inputs=[coverage_selector, view_state],
        outputs=coverage_detail,
        api_name=False,
    )
    dependency_selector.change(
        lambda selection, state: _select_detail(selection, state, "dependencies"),
        inputs=[dependency_selector, view_state],
        outputs=dependency_detail,
        api_name=False,
    )
    package_selector.change(
        lambda selection, state: _select_detail(selection, state, "packages"),
        inputs=[package_selector, view_state],
        outputs=package_detail,
        api_name=False,
    )
    vulnerability_selector.change(
        lambda selection, state: _select_detail(selection, state, "vulnerabilities"),
        inputs=[vulnerability_selector, view_state],
        outputs=vulnerability_detail,
        api_name=False,
    )
    finding_selector.change(
        lambda selection, state: _select_detail(selection, state, "findings"),
        inputs=[finding_selector, view_state],
        outputs=finding_detail,
        api_name=False,
    )

if __name__ == "__main__":
    demo.launch(theme=gr.themes.Soft(), css=APP_CSS)