Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,62 @@ runtime-confirmed findings, but only once there is runtime data.

![SCA vulnerabilities list with the Runtime column and a highlighted conflict](assets/sca-runtime.jpg)

### Runtime ingest API

Live sensors send evidence to the same server your CLI results go to. They
authenticate with **the same Secfoo API key**, so one key gives one view of
static findings and runtime evidence. `secfoo serve` implements this API; the
hosted service implements the same contract.

**On a self-hosted `secfoo serve`, issue a key:**

```bash
secfoo serve key create --name payments-prod-sensor --project payments-api --runtime-only
secfoo serve key list
secfoo serve key revoke 3
secfoo cloud login --api-key <key> --portal-url https://secfoo.internal # the CLI shares the same key
```

The key is printed once. Only a SHA-256 of it is stored. `--project` limits a
key to one project, and `--runtime-only` limits it to these endpoints; both
are recommended for production clusters.

**Sensor configuration**, set explicitly at deploy time:

```bash
SECFOO_API_KEY_FILE=/var/run/secrets/secfoo/api-key # or SECFOO_API_KEY; mount it from a secret, never bake it into an image
SECFOO_SERVER=https://secfoo.internal # default: the portal URL `secfoo cloud login` saved
SECFOO_PROJECT=https://github.com/acme/payments-api # the identifier exactly as `secfoo run` recorded it
SECFOO_APP=payments-api
SECFOO_ENV=prod
```

A missing key, server or project is an error. There is no fallback account.
`SECFOO_SERVER` must be `https://`, or a loopback `http://` address.

**Endpoints** (`Authorization: Bearer <key>`; `project` is required on each):

| Endpoint | Purpose |
|---|---|
| `GET /api/runtime/v1/watchlist?project=` | The watchlist above, with an `ETag`. Send `If-None-Match` to get a cheap `304`. |
| `POST /api/runtime/v1/events?project=&app=&env=` | An NDJSON batch in the evidence format (gzip allowed, `Content-Encoding: gzip`). It uses the same validation and idempotent upsert as `secfoo runtime ingest`, so a retried batch never double-counts. `app` / `env` fill session lines that don't set them. Returns the counts stored and any skipped lines. |
| `POST /api/runtime/v1/heartbeat?project=` | JSON `{sensor_id, version, app, env, instances, dropped}`. Registers the sensor and sums dropped events. |
| `GET /api/runtime/v1/evidence?project=` | The runtime state of each open SCA finding (state, routes, coverage, conflict), for the CLI and CI. |

- **Errors:** `401` for a missing, unknown or revoked key; `403` for a key
without access to the project; `404` for an unknown project (it is not
auto-created; run `secfoo run` first); `413` for oversized batches; `429`
when rate-limited.
- **Limits:** 5 MiB request body, 20 MiB decompressed, 50,000 lines per
batch, and 120 requests per minute per key.
- **Logging:** keys and event payloads are never logged or echoed in errors.

**TLS.** `secfoo serve` binds `127.0.0.1` by default. On any other `--host`,
it refuses API keys over plain HTTP. Put a TLS-terminating proxy in front
(e.g. Caddy, nginx, or a cloud load balancer forwarding `X-Forwarded-Proto`)
and run uvicorn behind it. `--allow-insecure-runtime` disables the check, for
a trusted private network only.

## Install

Pick whichever fits how you work — they're all the same tool underneath.
Expand Down
78 changes: 74 additions & 4 deletions src/secfoo/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -617,18 +617,88 @@ def show(
console.print("[yellow]No report content available for this run.[/]")


@app.command()
serve_app = typer.Typer(
name="serve", invoke_without_command=True,
help="Launch the local web dashboard (and runtime ingest API). `secfoo serve key` manages its API keys.",
)
serve_key_app = typer.Typer(name="key", help="API keys for runtime sensors and the CLI on this server (#66).")
serve_app.add_typer(serve_key_app, name="key")
app.add_typer(serve_app, name="serve")


@serve_app.callback()
def serve(
host: str = typer.Option("127.0.0.1", "--host"),
port: int = typer.Option(8787, "--port"),
ctx: typer.Context,
host: Annotated[str, typer.Option("--host")] = "127.0.0.1",
port: Annotated[int, typer.Option("--port")] = 8787,
allow_insecure_runtime: Annotated[bool, typer.Option(
"--allow-insecure-runtime",
help="Accept runtime API keys over plain HTTP on a non-loopback --host. Prefer TLS in front instead.",
)] = False,
) -> None:
"""Launch the local web dashboard."""
if ctx.invoked_subcommand is not None:
return
import uvicorn

from secfoo.web.app import create_app

console.print(f"Serving dashboard at [bold]http://{host}:{port}[/]")
uvicorn.run(create_app(), host=host, port=port)
uvicorn.run(create_app(bind_host=host, allow_insecure_runtime=allow_insecure_runtime), host=host, port=port)


@serve_key_app.command(name="create")
def serve_key_create(
name: Annotated[str, typer.Option("--name", help="A label, e.g. payments-prod-sensor.")],
project: Annotated[str | None, typer.Option(
"--project", help="Limit the key to one project (id, name or path/URL). Default: every project.",
)] = None,
runtime_only: Annotated[bool, typer.Option(
"--runtime-only", help="Only allow the runtime endpoints (recommended for production clusters).",
)] = False,
) -> None:
"""Issue an API key. It is shown once: store it in a secret, never in an image."""
from secfoo.runtime import keys as keys_module

with RunRepository() as repo:
project_id = None
if project is not None:
project_id = repo.resolve_project_ref(project)
if project_id is None:
err_console.print(f"[red]Error:[/] no single project matches {project!r}.")
raise typer.Exit(code=1)
key, record = keys_module.create_key(
repo.connection, name=name, scope="runtime" if runtime_only else "all", project_id=project_id
)
err_console.print(f"Created key {record.id} ({record.name}, scope {record.scope}). It won't be shown again:")
print(key) # stdout, so `KEY=$(secfoo serve key create ...)` captures only the key


@serve_key_app.command(name="list")
def serve_key_list() -> None:
"""Keys on this server (never the keys themselves)."""
from secfoo.runtime import keys as keys_module

table = Table(show_header=True, header_style="bold")
for column in ("id", "name", "prefix", "scope", "project", "created", "last used", "status"):
table.add_column(column)
with RunRepository() as repo:
for k in keys_module.list_keys(repo.connection):
table.add_row(str(k.id), k.name, k.key_prefix + "…", k.scope, str(k.project_id or "all"),
k.created_at[:19], (k.last_used_at or "-")[:19], "active" if k.active else "revoked")
console.print(table)


@serve_key_app.command(name="revoke")
def serve_key_revoke(key_id: Annotated[int, typer.Argument(help="Key id from `secfoo serve key list`.")]) -> None:
"""Revoke a key. Sensors and CLIs using it are rejected from the next request."""
from secfoo.runtime import keys as keys_module

with RunRepository() as repo:
if not keys_module.revoke_key(repo.connection, key_id):
err_console.print(f"[red]Error:[/] no active key with id {key_id}.")
raise typer.Exit(code=1)
console.print(f"Revoked key {key_id}.")


@app.command()
Expand Down
Loading
Loading