startup-sbom 2.0.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.
- startup_sbom/__init__.py +45 -0
- startup_sbom/__main__.py +6 -0
- startup_sbom/ai/__init__.py +84 -0
- startup_sbom/ai/a2a.py +208 -0
- startup_sbom/ai/acp.py +274 -0
- startup_sbom/ai/agents.py +251 -0
- startup_sbom/ai/cli.py +289 -0
- startup_sbom/ai/grounding.py +321 -0
- startup_sbom/ai/index.py +578 -0
- startup_sbom/ai/mcp_server.py +360 -0
- startup_sbom/ai/orchestrator.py +537 -0
- startup_sbom/ai/outputs.py +222 -0
- startup_sbom/ai/prompts.py +114 -0
- startup_sbom/ai/providers.py +366 -0
- startup_sbom/ai/registry.py +417 -0
- startup_sbom/ai/tools.py +237 -0
- startup_sbom/ai/websearch.py +93 -0
- startup_sbom/binaries/__init__.py +20 -0
- startup_sbom/binaries/analyze.py +450 -0
- startup_sbom/binaries/elf.py +633 -0
- startup_sbom/binaries/linker.py +492 -0
- startup_sbom/binaries/privileged.py +169 -0
- startup_sbom/binaries/shebang.py +119 -0
- startup_sbom/boottime.py +90 -0
- startup_sbom/cli.py +398 -0
- startup_sbom/context.py +70 -0
- startup_sbom/data/NOTICE.md +74 -0
- startup_sbom/data/attack_techniques.json +1 -0
- startup_sbom/data/capec_attack.json +1 -0
- startup_sbom/data/cwe.json +1 -0
- startup_sbom/data/kev_attack.json +1 -0
- startup_sbom/distro.py +456 -0
- startup_sbom/errors.py +47 -0
- startup_sbom/explain/__init__.py +704 -0
- startup_sbom/explain/attribution.py +430 -0
- startup_sbom/explain/counterfactual.py +394 -0
- startup_sbom/explain/global_.py +145 -0
- startup_sbom/explain/provenance.py +144 -0
- startup_sbom/explain/ssvc_trace.py +211 -0
- startup_sbom/initsys/__init__.py +279 -0
- startup_sbom/initsys/base.py +28 -0
- startup_sbom/initsys/cmdline.py +1200 -0
- startup_sbom/initsys/container.py +163 -0
- startup_sbom/initsys/inittab.py +208 -0
- startup_sbom/initsys/netconf.py +424 -0
- startup_sbom/initsys/openrc.py +338 -0
- startup_sbom/initsys/runit.py +235 -0
- startup_sbom/initsys/s6.py +177 -0
- startup_sbom/initsys/supervisord.py +259 -0
- startup_sbom/initsys/systemd/__init__.py +10 -0
- startup_sbom/initsys/systemd/analyzer.py +433 -0
- startup_sbom/initsys/systemd/boot.py +141 -0
- startup_sbom/initsys/systemd/exec.py +84 -0
- startup_sbom/initsys/systemd/hardening.py +1283 -0
- startup_sbom/initsys/systemd/loader.py +494 -0
- startup_sbom/initsys/systemd/parser.py +391 -0
- startup_sbom/initsys/systemd/sockets.py +122 -0
- startup_sbom/initsys/sysv.py +337 -0
- startup_sbom/intel/__init__.py +39 -0
- startup_sbom/intel/cli.py +83 -0
- startup_sbom/intel/exploits.py +332 -0
- startup_sbom/intel/extract.py +372 -0
- startup_sbom/intel/fetcher.py +420 -0
- startup_sbom/intel/prefetch.py +532 -0
- startup_sbom/intel/render.py +188 -0
- startup_sbom/intel/search.py +186 -0
- startup_sbom/intel/select.py +214 -0
- startup_sbom/intel/sources.py +420 -0
- startup_sbom/intel/store.py +165 -0
- startup_sbom/langpkgs/__init__.py +135 -0
- startup_sbom/langpkgs/golang.py +303 -0
- startup_sbom/langpkgs/java.py +241 -0
- startup_sbom/langpkgs/npm.py +191 -0
- startup_sbom/langpkgs/python.py +262 -0
- startup_sbom/langpkgs/rust.py +156 -0
- startup_sbom/legacy.py +86 -0
- startup_sbom/log.py +27 -0
- startup_sbom/lz4frame.py +138 -0
- startup_sbom/models.py +900 -0
- startup_sbom/net.py +327 -0
- startup_sbom/pipeline.py +330 -0
- startup_sbom/pkgdb/__init__.py +99 -0
- startup_sbom/pkgdb/alpm.py +326 -0
- startup_sbom/pkgdb/apk.py +264 -0
- startup_sbom/pkgdb/base.py +41 -0
- startup_sbom/pkgdb/dpkg.py +415 -0
- startup_sbom/pkgdb/ownership.py +77 -0
- startup_sbom/pkgdb/rpm/__init__.py +23 -0
- startup_sbom/pkgdb/rpm/bdb.py +116 -0
- startup_sbom/pkgdb/rpm/header.py +281 -0
- startup_sbom/pkgdb/rpm/ndb.py +67 -0
- startup_sbom/pkgdb/rpm/pgp.py +173 -0
- startup_sbom/pkgdb/rpm/reader.py +613 -0
- startup_sbom/pkgdb/rpm/sqlite.py +81 -0
- startup_sbom/purls.py +192 -0
- startup_sbom/py.typed +0 -0
- startup_sbom/report/__init__.py +36 -0
- startup_sbom/report/assets/report.css +75 -0
- startup_sbom/report/assets/report.js +221 -0
- startup_sbom/report/cdxvex.py +115 -0
- startup_sbom/report/console.py +401 -0
- startup_sbom/report/cyclonedx.py +818 -0
- startup_sbom/report/graphs.py +313 -0
- startup_sbom/report/html.py +625 -0
- startup_sbom/report/markdown.py +502 -0
- startup_sbom/report/native.py +318 -0
- startup_sbom/report/openvex.py +179 -0
- startup_sbom/report/pdf/__init__.py +81 -0
- startup_sbom/report/pdf/engine.py +142 -0
- startup_sbom/report/pdf/templates/components.typ +303 -0
- startup_sbom/report/pdf/templates/report.typ +852 -0
- startup_sbom/report/pdf/viewmodel.py +1031 -0
- startup_sbom/report/sarif.py +363 -0
- startup_sbom/report/spdx.py +244 -0
- startup_sbom/report/writer.py +185 -0
- startup_sbom/rootfs/__init__.py +30 -0
- startup_sbom/rootfs/base.py +325 -0
- startup_sbom/rootfs/caps.py +79 -0
- startup_sbom/rootfs/directory.py +131 -0
- startup_sbom/rootfs/tar.py +355 -0
- startup_sbom/sources/__init__.py +29 -0
- startup_sbom/sources/archive.py +370 -0
- startup_sbom/sources/compression.py +181 -0
- startup_sbom/sources/engine.py +471 -0
- startup_sbom/sources/host.py +128 -0
- startup_sbom/sources/oci.py +346 -0
- startup_sbom/sources/targets.py +262 -0
- startup_sbom/sources/validate.py +205 -0
- startup_sbom/supplychain/__init__.py +102 -0
- startup_sbom/supplychain/blast.py +199 -0
- startup_sbom/supplychain/eol.py +138 -0
- startup_sbom/supplychain/graph.py +436 -0
- startup_sbom/supplychain/integrity.py +221 -0
- startup_sbom/supplychain/provenance.py +494 -0
- startup_sbom/supplychain/repos.py +1505 -0
- startup_sbom/supplychain/vendors.py +186 -0
- startup_sbom/textsafe.py +67 -0
- startup_sbom/threat/__init__.py +20 -0
- startup_sbom/threat/attack.py +232 -0
- startup_sbom/threat/chains.py +276 -0
- startup_sbom/threat/cvss.py +151 -0
- startup_sbom/threat/cwe.py +168 -0
- startup_sbom/threat/facts.py +314 -0
- startup_sbom/threat/narrative.py +156 -0
- startup_sbom/threat/reachability.py +354 -0
- startup_sbom/threat/reverse.py +533 -0
- startup_sbom/threat/rules.py +338 -0
- startup_sbom/threat/scoring.py +170 -0
- startup_sbom/threat/ssvc.py +153 -0
- startup_sbom/tui/__init__.py +31 -0
- startup_sbom/tui/app.py +213 -0
- startup_sbom/tui/commands.py +59 -0
- startup_sbom/tui/data.py +225 -0
- startup_sbom/tui/integrations.py +157 -0
- startup_sbom/tui/messages.py +95 -0
- startup_sbom/tui/render.py +561 -0
- startup_sbom/tui/safe.py +49 -0
- startup_sbom/tui/screens/__init__.py +1 -0
- startup_sbom/tui/screens/ai_panel.py +179 -0
- startup_sbom/tui/screens/article.py +97 -0
- startup_sbom/tui/screens/export.py +119 -0
- startup_sbom/tui/screens/home.py +125 -0
- startup_sbom/tui/screens/open_report.py +85 -0
- startup_sbom/tui/screens/results.py +896 -0
- startup_sbom/tui/screens/scan.py +218 -0
- startup_sbom/tui/screens/settings.py +306 -0
- startup_sbom/tui/screens/target.py +274 -0
- startup_sbom/tui/state.py +506 -0
- startup_sbom/tui/styles.tcss +318 -0
- startup_sbom/tui/widgets.py +67 -0
- startup_sbom/viz/__init__.py +9 -0
- startup_sbom/viz/svg.py +482 -0
- startup_sbom/viz/terminal.py +935 -0
- startup_sbom/vulns/__init__.py +25 -0
- startup_sbom/vulns/arch.py +145 -0
- startup_sbom/vulns/base.py +22 -0
- startup_sbom/vulns/epss.py +167 -0
- startup_sbom/vulns/kev.py +62 -0
- startup_sbom/vulns/match.py +227 -0
- startup_sbom/vulns/normalize.py +345 -0
- startup_sbom/vulns/nvd.py +277 -0
- startup_sbom/vulns/osv.py +651 -0
- startup_sbom/vulns/versions.py +543 -0
- startup_sbom-2.0.0.dist-info/METADATA +301 -0
- startup_sbom-2.0.0.dist-info/RECORD +188 -0
- startup_sbom-2.0.0.dist-info/WHEEL +4 -0
- startup_sbom-2.0.0.dist-info/entry_points.txt +3 -0
- startup_sbom-2.0.0.dist-info/licenses/LICENSE +21 -0
startup_sbom/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""startup-sbom: what runs at boot, what it is made of, and how it can be attacked.
|
|
2
|
+
|
|
3
|
+
Library use::
|
|
4
|
+
|
|
5
|
+
from startup_sbom import ScanOptions, scan
|
|
6
|
+
|
|
7
|
+
report = scan("oci-dir:./image", ScanOptions(offline=True))
|
|
8
|
+
for threat in report.threats:
|
|
9
|
+
print(threat.ssvc.decision if threat.ssvc else "-", threat.title)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
__version__ = version("startup-sbom")
|
|
19
|
+
except PackageNotFoundError: # pragma: no cover - running from a source tree
|
|
20
|
+
__version__ = "0.0.0+unknown"
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from startup_sbom.context import ScanOptions
|
|
24
|
+
from startup_sbom.models import Report
|
|
25
|
+
|
|
26
|
+
__all__ = ["Report", "ScanOptions", "__version__", "scan"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def scan(target: str, options: ScanOptions | None = None, **kwargs: Any) -> Report:
|
|
30
|
+
"""Analyse ``target`` (see :func:`startup_sbom.pipeline.scan`)."""
|
|
31
|
+
from startup_sbom.pipeline import scan as _scan
|
|
32
|
+
|
|
33
|
+
return _scan(target, options, **kwargs)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def __getattr__(name: str) -> Any:
|
|
37
|
+
if name == "ScanOptions":
|
|
38
|
+
from startup_sbom.context import ScanOptions
|
|
39
|
+
|
|
40
|
+
return ScanOptions
|
|
41
|
+
if name == "Report":
|
|
42
|
+
from startup_sbom.models import Report
|
|
43
|
+
|
|
44
|
+
return Report
|
|
45
|
+
raise AttributeError(name)
|
startup_sbom/__main__.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Agentic AI analysis over a finished scan report (optional ``ai`` extra).
|
|
2
|
+
|
|
3
|
+
The deterministic report stays the source of truth. Agents only *read* it through
|
|
4
|
+
paged, size-capped, spotlighted tools, every output is machine-checked against the
|
|
5
|
+
report ids and numbers (:mod:`startup_sbom.ai.grounding`), and the result is an
|
|
6
|
+
:class:`~startup_sbom.models.AIAssessment` stored in ``report.ai``::
|
|
7
|
+
|
|
8
|
+
from startup_sbom.ai import analyze
|
|
9
|
+
|
|
10
|
+
report.ai = analyze(report, profile="cheap", max_cost_usd=0.50)
|
|
11
|
+
|
|
12
|
+
Importing this package is cheap: ``pydantic_ai``, ``mcp`` and ``fasta2a`` are only
|
|
13
|
+
imported by the submodules that need them, with a friendly error when the extra
|
|
14
|
+
is missing.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import importlib
|
|
20
|
+
from typing import TYPE_CHECKING, Any
|
|
21
|
+
|
|
22
|
+
from startup_sbom.errors import StartupSBOMError
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from startup_sbom.ai.orchestrator import analyze, analyze_async
|
|
26
|
+
from startup_sbom.ai.outputs import render_assessment
|
|
27
|
+
from startup_sbom.ai.providers import available_providers
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"AIBudgetError",
|
|
31
|
+
"AIConfigError",
|
|
32
|
+
"AIError",
|
|
33
|
+
"AIUnavailableError",
|
|
34
|
+
"analyze",
|
|
35
|
+
"analyze_async",
|
|
36
|
+
"available_providers",
|
|
37
|
+
"render_assessment",
|
|
38
|
+
"require",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
INSTALL_HINT = "pip install 'startup-sbom[ai]'"
|
|
42
|
+
SERVER_INSTALL_HINT = "pip install 'startup-sbom[agents-server]'"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AIError(StartupSBOMError):
|
|
46
|
+
"""Base class for errors raised by the AI layer."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AIUnavailableError(AIError):
|
|
50
|
+
"""An optional dependency of the AI layer is not installed."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AIConfigError(AIError):
|
|
54
|
+
"""Provider, model, agent or server configuration is invalid."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class AIBudgetError(AIError):
|
|
58
|
+
"""A run hit its usage limits or its ``max_cost_usd`` guard."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def require(module: str, *, hint: str = INSTALL_HINT) -> Any:
|
|
62
|
+
"""Import ``module`` or raise :class:`AIUnavailableError` naming the extra to install."""
|
|
63
|
+
try:
|
|
64
|
+
return importlib.import_module(module)
|
|
65
|
+
except ImportError as exc:
|
|
66
|
+
raise AIUnavailableError(
|
|
67
|
+
f"the AI features need the optional dependency "
|
|
68
|
+
f"{module.split('.', maxsplit=1)[0]!r}: {hint}"
|
|
69
|
+
) from exc
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
_LAZY = {
|
|
73
|
+
"analyze": "startup_sbom.ai.orchestrator",
|
|
74
|
+
"analyze_async": "startup_sbom.ai.orchestrator",
|
|
75
|
+
"render_assessment": "startup_sbom.ai.outputs",
|
|
76
|
+
"available_providers": "startup_sbom.ai.providers",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def __getattr__(name: str) -> Any:
|
|
81
|
+
module = _LAZY.get(name)
|
|
82
|
+
if module is None:
|
|
83
|
+
raise AttributeError(name)
|
|
84
|
+
return getattr(importlib.import_module(module), name)
|
startup_sbom/ai/a2a.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Serve the orchestrator and specialists over A2A 1.0 with ``fasta2a``.
|
|
2
|
+
|
|
3
|
+
Pydantic AI 2 removed ``Agent.to_a2a()``; the replacement is building ``FastA2A``
|
|
4
|
+
around ``fasta2a.pydantic_ai.AgentWorker``, with two fixes verified against the
|
|
5
|
+
official ``a2a-sdk`` client:
|
|
6
|
+
|
|
7
|
+
1. **deps**: the worker calls ``agent.run(message_history=...)`` without deps, so the
|
|
8
|
+
lifespan binds them with ``agent.override(deps=...)`` (contextvars propagate into
|
|
9
|
+
the worker's task group).
|
|
10
|
+
2. **timestamps**: fasta2a 2.1.1 writes naive timestamps the a2a-sdk (protobuf JSON)
|
|
11
|
+
rejects; :class:`UTCStorage` rewrites them as UTC ``...Z``.
|
|
12
|
+
|
|
13
|
+
The orchestrator card is served at the root; each local specialist gets its own card
|
|
14
|
+
under ``/agents/<name>``. Binds 127.0.0.1 by default; a bearer token
|
|
15
|
+
(``STARTUP_SBOM_AGENT_TOKEN``) is required on any other interface.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from collections.abc import AsyncIterator, Sequence
|
|
21
|
+
from contextlib import AsyncExitStack, ExitStack, asynccontextmanager
|
|
22
|
+
from datetime import UTC, datetime
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from startup_sbom.ai import SERVER_INSTALL_HINT, AIUnavailableError
|
|
26
|
+
from startup_sbom.ai.acp import TokenAuthMiddleware
|
|
27
|
+
from startup_sbom.ai.index import ReportIndex
|
|
28
|
+
from startup_sbom.ai.orchestrator import ModelSet, build_orchestrator
|
|
29
|
+
from startup_sbom.ai.registry import AgentRegistry, AgentSpec
|
|
30
|
+
from startup_sbom.ai.tools import AgentDeps, report_toolset
|
|
31
|
+
from startup_sbom.models import Report
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
from fasta2a import FastA2A
|
|
35
|
+
from fasta2a.broker import InMemoryBroker
|
|
36
|
+
from fasta2a.pydantic_ai import AgentWorker
|
|
37
|
+
from fasta2a.schema import AgentProvider, Artifact, Message, Skill, Task, TaskState
|
|
38
|
+
from fasta2a.storage import InMemoryStorage
|
|
39
|
+
from pydantic_ai import Agent
|
|
40
|
+
from starlette.applications import Starlette
|
|
41
|
+
from starlette.routing import BaseRoute, Mount
|
|
42
|
+
from starlette.types import ASGIApp
|
|
43
|
+
except ImportError as exc: # pragma: no cover - exercised only without the extra
|
|
44
|
+
raise AIUnavailableError(f"the A2A server needs 'fasta2a': {SERVER_INSTALL_HINT}") from exc
|
|
45
|
+
|
|
46
|
+
PROVIDER = AgentProvider(
|
|
47
|
+
organization="startup-sbom", url="https://github.com/morpheuslord/Startup-SBOM"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class UTCStorage(InMemoryStorage): # type: ignore[misc] # fasta2a ships no types
|
|
52
|
+
"""``InMemoryStorage`` whose task timestamps are UTC with a ``Z`` suffix.
|
|
53
|
+
|
|
54
|
+
fasta2a 2.1.1 writes ``datetime.now().isoformat()`` (naive, no zone); the official
|
|
55
|
+
a2a-sdk client fails to parse that protobuf timestamp. Worth fixing upstream.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def _utc(task: Task) -> Task:
|
|
60
|
+
status = task.get("status")
|
|
61
|
+
if status is not None:
|
|
62
|
+
status["timestamp"] = datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
|
63
|
+
return task
|
|
64
|
+
|
|
65
|
+
async def submit_task(self, context_id: str, message: Message) -> Task:
|
|
66
|
+
return self._utc(await super().submit_task(context_id, message))
|
|
67
|
+
|
|
68
|
+
async def update_task(
|
|
69
|
+
self,
|
|
70
|
+
task_id: str,
|
|
71
|
+
state: TaskState,
|
|
72
|
+
new_artifacts: list[Artifact] | None = None,
|
|
73
|
+
new_messages: list[Message] | None = None,
|
|
74
|
+
) -> Task:
|
|
75
|
+
return self._utc(await super().update_task(task_id, state, new_artifacts, new_messages))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def agent_app(
|
|
79
|
+
agent: Agent[AgentDeps, Any],
|
|
80
|
+
deps: AgentDeps,
|
|
81
|
+
*,
|
|
82
|
+
name: str,
|
|
83
|
+
description: str,
|
|
84
|
+
url: str,
|
|
85
|
+
skills: list[Skill],
|
|
86
|
+
version: str,
|
|
87
|
+
extra_toolsets: bool = True,
|
|
88
|
+
) -> FastA2A:
|
|
89
|
+
"""One A2A app (card + JSON-RPC) around ``agent`` with deps bound in the lifespan."""
|
|
90
|
+
storage, broker = UTCStorage(), InMemoryBroker()
|
|
91
|
+
worker = AgentWorker(agent=agent, broker=broker, storage=storage)
|
|
92
|
+
|
|
93
|
+
@asynccontextmanager
|
|
94
|
+
async def lifespan(app: FastA2A) -> AsyncIterator[None]:
|
|
95
|
+
async with AsyncExitStack() as stack:
|
|
96
|
+
await stack.enter_async_context(app.task_manager)
|
|
97
|
+
await stack.enter_async_context(agent)
|
|
98
|
+
overrides = ExitStack()
|
|
99
|
+
stack.enter_context(overrides)
|
|
100
|
+
if extra_toolsets:
|
|
101
|
+
overrides.enter_context(agent.override(deps=deps, toolsets=[report_toolset()]))
|
|
102
|
+
else:
|
|
103
|
+
overrides.enter_context(agent.override(deps=deps))
|
|
104
|
+
await stack.enter_async_context(worker.run())
|
|
105
|
+
yield
|
|
106
|
+
|
|
107
|
+
return FastA2A(
|
|
108
|
+
storage=storage,
|
|
109
|
+
broker=broker,
|
|
110
|
+
name=name,
|
|
111
|
+
url=url,
|
|
112
|
+
version=version,
|
|
113
|
+
description=description,
|
|
114
|
+
provider=PROVIDER,
|
|
115
|
+
skills=skills,
|
|
116
|
+
default_input_modes=["text/plain"],
|
|
117
|
+
default_output_modes=["application/json"],
|
|
118
|
+
lifespan=lifespan,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _skill(spec: AgentSpec) -> Skill:
|
|
123
|
+
return Skill(
|
|
124
|
+
id=spec.name,
|
|
125
|
+
name=spec.name.replace("_", " "),
|
|
126
|
+
description=spec.description,
|
|
127
|
+
tags=list(dict.fromkeys([*spec.tags, "security", "sbom"])),
|
|
128
|
+
input_modes=["text/plain"],
|
|
129
|
+
output_modes=["application/json"],
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def build_a2a_apps(
|
|
134
|
+
report: Report,
|
|
135
|
+
*,
|
|
136
|
+
models: ModelSet,
|
|
137
|
+
registry: AgentRegistry,
|
|
138
|
+
base_url: str = "http://127.0.0.1:8770",
|
|
139
|
+
) -> dict[str, FastA2A]:
|
|
140
|
+
"""``{"": orchestrator app, "<name>": specialist app, ...}`` keyed by mount name."""
|
|
141
|
+
from startup_sbom import __version__
|
|
142
|
+
|
|
143
|
+
deps = AgentDeps(index=ReportIndex(report))
|
|
144
|
+
apps: dict[str, FastA2A] = {}
|
|
145
|
+
orchestrator = build_orchestrator(registry, models)
|
|
146
|
+
apps[""] = agent_app(
|
|
147
|
+
orchestrator,
|
|
148
|
+
deps,
|
|
149
|
+
name="startup-sbom-orchestrator",
|
|
150
|
+
description=(
|
|
151
|
+
"Lead analyst over one startup-sbom scan report: delegates to specialists "
|
|
152
|
+
"and returns one grounded, machine-checked assessment (read-only)."
|
|
153
|
+
),
|
|
154
|
+
url=base_url,
|
|
155
|
+
skills=[_skill(spec) for spec in registry.specs()],
|
|
156
|
+
version=__version__,
|
|
157
|
+
extra_toolsets=False, # the orchestrator already owns its toolsets
|
|
158
|
+
)
|
|
159
|
+
for spec in registry.specs():
|
|
160
|
+
if spec.build is None:
|
|
161
|
+
continue # remote agents are consumed, never re-exposed
|
|
162
|
+
model = models.orchestrator if spec.tier != "cheap" else models.specialist
|
|
163
|
+
apps[spec.name] = agent_app(
|
|
164
|
+
spec.build(model),
|
|
165
|
+
deps,
|
|
166
|
+
name=f"startup-sbom-{spec.name.replace('_', '-')}",
|
|
167
|
+
description=spec.description,
|
|
168
|
+
url=f"{base_url}/agents/{spec.name}",
|
|
169
|
+
skills=[_skill(spec)],
|
|
170
|
+
version=__version__,
|
|
171
|
+
extra_toolsets=spec.inject_report_tools,
|
|
172
|
+
)
|
|
173
|
+
return apps
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def build_a2a_app(
|
|
177
|
+
report: Report,
|
|
178
|
+
*,
|
|
179
|
+
models: ModelSet,
|
|
180
|
+
registry: AgentRegistry,
|
|
181
|
+
base_url: str = "http://127.0.0.1:8770",
|
|
182
|
+
token: str | None = None,
|
|
183
|
+
extra_routes: Sequence[BaseRoute] = (),
|
|
184
|
+
) -> ASGIApp:
|
|
185
|
+
"""One ASGI app: orchestrator at ``/``, specialists at ``/agents/<name>``.
|
|
186
|
+
|
|
187
|
+
Mounted Starlette apps get no lifespan events, so the parent lifespan enters each
|
|
188
|
+
sub-app's lifespan (task manager, ``agent.override(deps=...)``, worker) itself.
|
|
189
|
+
``extra_routes`` (e.g. a ``Mount("/acp", ...)``) are matched before the root app.
|
|
190
|
+
"""
|
|
191
|
+
apps = build_a2a_apps(report, models=models, registry=registry, base_url=base_url)
|
|
192
|
+
|
|
193
|
+
@asynccontextmanager
|
|
194
|
+
async def lifespan(_: Starlette) -> AsyncIterator[None]:
|
|
195
|
+
async with AsyncExitStack() as stack:
|
|
196
|
+
for sub in apps.values():
|
|
197
|
+
await stack.enter_async_context(sub.router.lifespan_context(sub))
|
|
198
|
+
yield
|
|
199
|
+
|
|
200
|
+
routes: list[BaseRoute] = [
|
|
201
|
+
Mount(f"/agents/{name}", app=sub) for name, sub in apps.items() if name
|
|
202
|
+
]
|
|
203
|
+
routes.extend(extra_routes)
|
|
204
|
+
routes.append(Mount("/", app=apps[""]))
|
|
205
|
+
app: ASGIApp = Starlette(routes=routes, lifespan=lifespan)
|
|
206
|
+
if token:
|
|
207
|
+
app = TokenAuthMiddleware(app, token)
|
|
208
|
+
return app
|
startup_sbom/ai/acp.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""ACP REST facade: serve the agent registry over the Agent Communication Protocol.
|
|
2
|
+
|
|
3
|
+
``acp-sdk`` is archived (it merged into A2A in 2025 and breaks on uvicorn >= 0.35), so
|
|
4
|
+
this is a dependency-free Starlette implementation of the subset ACP clients use:
|
|
5
|
+
``GET /ping``, ``GET /agents``, ``GET /agents/{name}`` (manifest), ``POST /runs``
|
|
6
|
+
(``sync`` | ``async`` | ``stream``) and ``GET /runs/{id}``. Only *local* agents
|
|
7
|
+
(built-ins and allow-listed plugins) are exposed; remote agents are never proxied.
|
|
8
|
+
|
|
9
|
+
Binds 127.0.0.1 by default. A bearer token (``STARTUP_SBOM_AGENT_TOKEN``) is
|
|
10
|
+
optional on loopback and **required** on any other interface.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import ipaddress
|
|
17
|
+
import json
|
|
18
|
+
import uuid
|
|
19
|
+
from collections.abc import Awaitable, Callable
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from datetime import UTC, datetime
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from startup_sbom.ai import INSTALL_HINT, AIConfigError, AIUnavailableError
|
|
25
|
+
from startup_sbom.ai.index import ReportIndex, sanitize
|
|
26
|
+
from startup_sbom.ai.orchestrator import ModelSet, build_orchestrator
|
|
27
|
+
from startup_sbom.ai.registry import AgentRegistry, AgentSpec
|
|
28
|
+
from startup_sbom.ai.tools import AgentDeps, report_toolset
|
|
29
|
+
from startup_sbom.models import Report
|
|
30
|
+
from startup_sbom.textsafe import clean
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
from pydantic import BaseModel
|
|
34
|
+
from starlette.applications import Starlette
|
|
35
|
+
from starlette.requests import Request
|
|
36
|
+
from starlette.responses import JSONResponse, Response, StreamingResponse
|
|
37
|
+
from starlette.routing import Route
|
|
38
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
39
|
+
except ImportError as exc: # pragma: no cover - exercised only without the extra
|
|
40
|
+
raise AIUnavailableError(f"the agent servers need 'starlette': {INSTALL_HINT}") from exc
|
|
41
|
+
|
|
42
|
+
MAX_BODY_BYTES = 262_144
|
|
43
|
+
MAX_PROMPT_CHARS = 20_000
|
|
44
|
+
TOKEN_ENV = "STARTUP_SBOM_AGENT_TOKEN" # noqa: S105 - env var name, not a secret
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_loopback_host(host: str) -> bool:
|
|
48
|
+
"""Whether ``host`` only binds/means the loopback interface."""
|
|
49
|
+
if host in ("localhost", "localhost.localdomain"):
|
|
50
|
+
return True
|
|
51
|
+
try:
|
|
52
|
+
return ipaddress.ip_address(host.strip("[]")).is_loopback
|
|
53
|
+
except ValueError:
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def require_token(host: str, token: str | None) -> None:
|
|
58
|
+
"""Refuse to serve agents on a non-loopback interface without a bearer token."""
|
|
59
|
+
if token is None and not is_loopback_host(host):
|
|
60
|
+
raise AIConfigError(
|
|
61
|
+
f"refusing to bind {host!r} without authentication: set {TOKEN_ENV} (or bind 127.0.0.1)"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TokenAuthMiddleware:
|
|
66
|
+
"""Require ``Authorization: Bearer <token>`` on every request."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, app: ASGIApp, token: str) -> None:
|
|
69
|
+
self.app = app
|
|
70
|
+
self._expected = f"Bearer {token}".encode()
|
|
71
|
+
|
|
72
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
73
|
+
if scope["type"] == "http":
|
|
74
|
+
headers = dict(scope.get("headers") or [])
|
|
75
|
+
if headers.get(b"authorization") != self._expected:
|
|
76
|
+
response = JSONResponse(
|
|
77
|
+
{"code": "unauthorized", "message": "missing or wrong bearer token"},
|
|
78
|
+
status_code=401,
|
|
79
|
+
)
|
|
80
|
+
await response(scope, receive, send)
|
|
81
|
+
return
|
|
82
|
+
await self.app(scope, receive, send)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# --------------------------------------------------------------------------- facade
|
|
86
|
+
Runner = Callable[[str], Awaitable[tuple[str, str]]]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class _Served:
|
|
91
|
+
manifest: dict[str, Any]
|
|
92
|
+
run: Runner
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _manifest(spec: AgentSpec) -> dict[str, Any]:
|
|
96
|
+
return {
|
|
97
|
+
"name": spec.name,
|
|
98
|
+
"description": spec.description,
|
|
99
|
+
"metadata": {
|
|
100
|
+
"tags": list(spec.tags),
|
|
101
|
+
"framework": "pydantic-ai",
|
|
102
|
+
"capabilities": [{"name": "grounded-analysis"}],
|
|
103
|
+
"author": {"name": "startup-sbom"},
|
|
104
|
+
},
|
|
105
|
+
"input_content_types": ["text/plain"],
|
|
106
|
+
"output_content_types": ["application/json"],
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _runner(spec: AgentSpec, models: ModelSet, deps: AgentDeps) -> Runner:
|
|
111
|
+
model = models.orchestrator if spec.tier != "cheap" else models.specialist
|
|
112
|
+
|
|
113
|
+
async def run(prompt: str) -> tuple[str, str]:
|
|
114
|
+
assert spec.build is not None # noqa: S101 - remote specs are filtered out
|
|
115
|
+
agent = spec.build(model)
|
|
116
|
+
toolsets = [report_toolset()] if spec.inject_report_tools else None
|
|
117
|
+
result = await agent.run(prompt, deps=deps, toolsets=toolsets)
|
|
118
|
+
output = result.output
|
|
119
|
+
if isinstance(output, BaseModel):
|
|
120
|
+
return output.model_dump_json(), "application/json"
|
|
121
|
+
return json.dumps(sanitize(output), default=str), "application/json"
|
|
122
|
+
|
|
123
|
+
return run
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _now() -> str:
|
|
127
|
+
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _error(code: str, message: str, status: int) -> JSONResponse:
|
|
131
|
+
return JSONResponse({"code": code, "message": message}, status_code=status)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def build_acp_app(
|
|
135
|
+
report: Report,
|
|
136
|
+
*,
|
|
137
|
+
models: ModelSet,
|
|
138
|
+
registry: AgentRegistry,
|
|
139
|
+
include_orchestrator: bool = True,
|
|
140
|
+
token: str | None = None,
|
|
141
|
+
) -> ASGIApp:
|
|
142
|
+
"""The ACP facade app over ``registry`` (local agents) plus the orchestrator."""
|
|
143
|
+
deps = AgentDeps(index=ReportIndex(report))
|
|
144
|
+
served: dict[str, _Served] = {}
|
|
145
|
+
for spec in registry.specs():
|
|
146
|
+
if spec.build is None:
|
|
147
|
+
continue # never proxy remote agents
|
|
148
|
+
served[spec.name] = _Served(_manifest(spec), _runner(spec, models, deps))
|
|
149
|
+
if include_orchestrator:
|
|
150
|
+
orchestrator = build_orchestrator(registry, models)
|
|
151
|
+
|
|
152
|
+
async def run_orchestrator(prompt: str) -> tuple[str, str]:
|
|
153
|
+
result = await orchestrator.run(prompt, deps=deps)
|
|
154
|
+
return result.output.model_dump_json(), "application/json"
|
|
155
|
+
|
|
156
|
+
served["orchestrator"] = _Served(
|
|
157
|
+
{
|
|
158
|
+
"name": "orchestrator",
|
|
159
|
+
"description": (
|
|
160
|
+
"Lead analyst: delegates to the specialists and synthesises one "
|
|
161
|
+
"grounded assessment of the loaded scan report"
|
|
162
|
+
),
|
|
163
|
+
"metadata": {"tags": ["orchestrator"], "framework": "pydantic-ai"},
|
|
164
|
+
"input_content_types": ["text/plain"],
|
|
165
|
+
"output_content_types": ["application/json"],
|
|
166
|
+
},
|
|
167
|
+
run_orchestrator,
|
|
168
|
+
)
|
|
169
|
+
runs: dict[str, dict[str, Any]] = {}
|
|
170
|
+
background: set[asyncio.Task[None]] = set()
|
|
171
|
+
|
|
172
|
+
async def ping(_: Request) -> Response:
|
|
173
|
+
return JSONResponse({})
|
|
174
|
+
|
|
175
|
+
async def list_agents(_: Request) -> Response:
|
|
176
|
+
return JSONResponse({"agents": [s.manifest for s in served.values()]})
|
|
177
|
+
|
|
178
|
+
async def get_agent(request: Request) -> Response:
|
|
179
|
+
entry = served.get(request.path_params["name"])
|
|
180
|
+
if entry is None:
|
|
181
|
+
return _error("not_found", "agent not found", 404)
|
|
182
|
+
return JSONResponse(entry.manifest)
|
|
183
|
+
|
|
184
|
+
async def get_run(request: Request) -> Response:
|
|
185
|
+
run = runs.get(request.path_params["run_id"])
|
|
186
|
+
if run is None:
|
|
187
|
+
return _error("not_found", "run not found", 404)
|
|
188
|
+
return JSONResponse(run)
|
|
189
|
+
|
|
190
|
+
async def create_run(request: Request) -> Response:
|
|
191
|
+
body_bytes = await request.body()
|
|
192
|
+
if len(body_bytes) > MAX_BODY_BYTES:
|
|
193
|
+
return _error("invalid_input", "request body too large", 413)
|
|
194
|
+
try:
|
|
195
|
+
body = json.loads(body_bytes)
|
|
196
|
+
except ValueError:
|
|
197
|
+
return _error("invalid_input", "body is not JSON", 400)
|
|
198
|
+
if not isinstance(body, dict):
|
|
199
|
+
return _error("invalid_input", "body must be a JSON object", 400)
|
|
200
|
+
entry = served.get(str(body.get("agent_name", "")))
|
|
201
|
+
if entry is None:
|
|
202
|
+
return _error("not_found", "agent not found", 404)
|
|
203
|
+
parts = [
|
|
204
|
+
str(part.get("content") or "")
|
|
205
|
+
for message in body.get("input") or []
|
|
206
|
+
if isinstance(message, dict)
|
|
207
|
+
for part in message.get("parts") or []
|
|
208
|
+
if isinstance(part, dict)
|
|
209
|
+
]
|
|
210
|
+
prompt = clean("\n".join(parts), max_len=MAX_PROMPT_CHARS, keep_newlines=True)
|
|
211
|
+
run: dict[str, Any] = {
|
|
212
|
+
"run_id": str(uuid.uuid4()),
|
|
213
|
+
"agent_name": body["agent_name"],
|
|
214
|
+
"session_id": body.get("session_id"),
|
|
215
|
+
"status": "in-progress",
|
|
216
|
+
"output": [],
|
|
217
|
+
"error": None,
|
|
218
|
+
"created_at": _now(),
|
|
219
|
+
"finished_at": None,
|
|
220
|
+
}
|
|
221
|
+
runs[run["run_id"]] = run
|
|
222
|
+
|
|
223
|
+
async def execute() -> None:
|
|
224
|
+
try:
|
|
225
|
+
content, ctype = await entry.run(prompt)
|
|
226
|
+
run["output"] = [
|
|
227
|
+
{
|
|
228
|
+
"role": f"agent/{run['agent_name']}",
|
|
229
|
+
"parts": [{"content_type": ctype, "content": content}],
|
|
230
|
+
"created_at": _now(),
|
|
231
|
+
"completed_at": _now(),
|
|
232
|
+
}
|
|
233
|
+
]
|
|
234
|
+
run["status"] = "completed"
|
|
235
|
+
except Exception as exc: # noqa: BLE001 - reported to the caller as a failed run
|
|
236
|
+
run["status"] = "failed"
|
|
237
|
+
run["error"] = {
|
|
238
|
+
"code": "server_error",
|
|
239
|
+
"message": clean(str(exc), max_len=300),
|
|
240
|
+
}
|
|
241
|
+
run["finished_at"] = _now()
|
|
242
|
+
|
|
243
|
+
mode = body.get("mode", "sync")
|
|
244
|
+
if mode == "async":
|
|
245
|
+
task = asyncio.create_task(execute())
|
|
246
|
+
background.add(task)
|
|
247
|
+
task.add_done_callback(background.discard)
|
|
248
|
+
return JSONResponse(run, status_code=202)
|
|
249
|
+
if mode == "stream":
|
|
250
|
+
|
|
251
|
+
async def sse() -> Any:
|
|
252
|
+
yield f"data: {json.dumps({'type': 'run.created', 'run': run})}\n\n"
|
|
253
|
+
await execute()
|
|
254
|
+
for message in run["output"]:
|
|
255
|
+
for part in message["parts"]:
|
|
256
|
+
yield f"data: {json.dumps({'type': 'message.part', 'part': part})}\n\n"
|
|
257
|
+
yield f"data: {json.dumps({'type': 'run.' + run['status'], 'run': run})}\n\n"
|
|
258
|
+
|
|
259
|
+
return StreamingResponse(sse(), media_type="text/event-stream")
|
|
260
|
+
await execute()
|
|
261
|
+
return JSONResponse(run)
|
|
262
|
+
|
|
263
|
+
app: ASGIApp = Starlette(
|
|
264
|
+
routes=[
|
|
265
|
+
Route("/ping", ping),
|
|
266
|
+
Route("/agents", list_agents),
|
|
267
|
+
Route("/agents/{name}", get_agent),
|
|
268
|
+
Route("/runs", create_run, methods=["POST"]),
|
|
269
|
+
Route("/runs/{run_id}", get_run),
|
|
270
|
+
]
|
|
271
|
+
)
|
|
272
|
+
if token:
|
|
273
|
+
app = TokenAuthMiddleware(app, token)
|
|
274
|
+
return app
|