File size: 3,133 Bytes
a8943e8
 
 
 
 
081f1c7
a8943e8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Render the security addendum's host table from config, so the addendum cannot drift from the code.

The table between the ``host-table`` markers in ``docs/security/addendum-knowledge-specialist.md`` is
generated from ``config/sources.yaml`` (hosts, licences) and ``config/tools_allowlist.yaml`` (which
endpoints call which source). ``tests/test_sources_hosts.py`` already proves every host a loaded tool
can reach is in ``sources.yaml``; this script carries that same list into the document the client's security reviewers read.

    python scripts/render_host_table.py          # rewrite the block in place
    python scripts/render_host_table.py --check  # exit 1 if the committed block is stale
"""

from __future__ import annotations

import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))

from src import config

ADDENDUM = ROOT / "docs" / "security" / "addendum-knowledge-specialist.md"
START = "<!-- host-table:start (generated by scripts/render_host_table.py; do not edit by hand) -->"
END = "<!-- host-table:end -->"


def endpoints_by_source() -> dict[str, list[str]]:
    used: dict[str, set[str]] = {}
    for tool in config.allowlist()["tools"].values():
        used.setdefault(tool["source"], set()).update(tool["endpoints"])
    return {sid: [e for e in config.ENDPOINTS if e in eps] for sid, eps in used.items()}


def render() -> str:
    by_source = endpoints_by_source()
    rows = [
        "| Source | Hosts called | Licence carried on every row | Called in Phase 1 by | Key needed |",
        "|---|---|---|---|---|",
    ]
    for sid, src in config.sources().items():
        eps = by_source.get(sid) or []
        called = ", ".join(f"`{e}`" for e in eps) if eps else "not called (declared ahead of use)"
        hosts = ", ".join(f"`{h}`" for h in src["hosts"])
        rows.append(f"| {src['name']} | {hosts} | {src['licence']} | {called} | none |")
    n_hosts = sum(len(s["hosts"]) for s in config.sources().values())
    n_called = sum(len(s["hosts"]) for sid, s in config.sources().items() if by_source.get(sid))
    rows.append("")
    rows.append(
        f"{n_hosts} hosts in all; {n_called} are called by a Phase 1 endpoint. Source of truth: "
        "`config/sources.yaml`, checked against the installed ToolUniverse by "
        "`tests/test_sources_hosts.py`."
    )
    return "\n".join([START, *rows, END])


def main(argv: list[str]) -> int:
    text = ADDENDUM.read_text(encoding="utf-8")
    if START not in text or END not in text:
        print(f"{ADDENDUM.name}: host-table markers missing", file=sys.stderr)
        return 2
    before, rest = text.split(START, 1)
    _, after = rest.split(END, 1)
    updated = before + render() + after
    if "--check" in argv:
        if updated != text:
            print(
                f"{ADDENDUM.name}: host table is stale; run scripts/render_host_table.py",
                file=sys.stderr,
            )
            return 1
        return 0
    ADDENDUM.write_text(updated, encoding="utf-8")
    return 0


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))