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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,21 @@ schemas and the `paramify` CLI — not the internal code.

## [Unreleased]

### Added

- `paramify scripts sync` — push each fetcher's entry script (`fetcher.py` /
`fetcher.sh`) to Paramify and CONNECT it to that fetcher's evidence set, so the
tenant records *how* each piece of evidence is generated. A provisioning step
separate from `paramify upload`: it reconciles the tenant to the repo GitOps-style
(marker-keyed identity in the script description, `fetcher.yaml` `version` as the
update signal, a sha256 drift guard), with `--dry-run` / `--force` /
`--reassociate` / `--json`. Backed by the new `uploaders/paramify_scripts/`
uploader and surfaced in the TUI's Paramify tab. Only `SCRIPT` associations are
automated; control / solution-capability / validator linkage stays Paramify-side.
- [`docs/uploader_design.md`](docs/uploader_design.md) — a dedicated uploader design
doc covering both built uploaders and the shared evidence-set identity model, and
a README section + docs-table entries pointing to it.

## [0.2.1-beta] - 2026-07-10

### Changed
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,27 @@ for how to create a Paramify API key with the required permissions. Chaining the
customer's job, not the runner's; `run_and_upload.sh` at the repo root is
example glue.

### Show how evidence is generated (optional)

Beyond the evidence itself, you can push each fetcher's **entry script** to
Paramify and link it to that fetcher's evidence set — so the tenant shows *how*
each piece of evidence was collected. This is a **provisioning** step, separate
from upload: run it when `fetchers/**` change, not on every collection.

```bash
paramify scripts sync --dry-run # preview the plan (read-only)
paramify scripts sync # push entry scripts + associate to evidence sets
```

It reconciles the tenant to the repo GitOps-style — keyed off a marker in each
script's `description` and the `fetcher.yaml` `version`, with a sha256 drift guard
(warn/skip unless `--force`) — and supports `--reassociate` and `--json`. Like the
uploader it talks Paramify REST v0 over HTTPS only and reads
`PARAMIFY_UPLOAD_API_TOKEN`. See
[`uploaders/paramify_scripts/README.md`](uploaders/paramify_scripts/README.md) for
the full model, or [`docs/uploader_design.md`](docs/uploader_design.md) for how it
fits alongside evidence upload.

---

## How it runs
Expand Down Expand Up @@ -348,6 +369,7 @@ fetchers/
comparators/ # cross-source comparators (template only so far)
uploaders/
paramify_evidence/ # push evidence to Paramify (built)
paramify_scripts/ # push fetcher entry scripts + associate to evidence sets (built)
paramify_issues/ # stub, not built yet
examples/ # sample run manifests
tests/ # framework test suite (pytest)
Expand Down Expand Up @@ -379,6 +401,8 @@ To add evidence collection for a new control or a new tool, see [`docs/authoring
| [`fetchers/k8s/README.md`](fetchers/k8s/README.md) | Kubernetes / EKS credential setup |
| [`fetchers/checkov/README.md`](fetchers/checkov/README.md) | Checkov setup + git token for IaC scanning |
| [`uploaders/paramify_evidence/README.md`](uploaders/paramify_evidence/README.md) | Paramify API key setup + upload options |
| [`uploaders/paramify_scripts/README.md`](uploaders/paramify_scripts/README.md) | Syncing fetcher entry scripts to Paramify + the association model |
| [`docs/uploader_design.md`](docs/uploader_design.md) | How both uploaders work + the shared evidence-set identity model |
| [`docs/authoring_a_fetcher.md`](docs/authoring_a_fetcher.md) | Writing a new fetcher from scratch |
| [`docs/fetcher_contract.md`](docs/fetcher_contract.md) | The binding runner↔fetcher contract |
| [`docs/run_manifest_reference.md`](docs/run_manifest_reference.md) | Manifest format reference |
Expand Down
132 changes: 132 additions & 0 deletions docs/uploader_design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# Uploader design

How this repo pushes to Paramify, and why it's shaped the way it is. This is the
dedicated companion to [`design.md`](design.md), which frames uploaders as one of
the framework's [separate stages](design.md#uploaders-as-a-separate-stage); the
detail lives here.

Two things reach Paramify today, both under `uploaders/`:

- **`paramify_evidence`** — attaches a completed run's evidence files to their
evidence sets. Runs **every collection**. Exposed as `paramify upload`.
- **`paramify_scripts`** — pushes each fetcher's entry script and connects it to
that fetcher's evidence set, so the tenant records *how* the evidence was
generated. A **provisioning** step, run only when `fetchers/**` change. Exposed
as `paramify scripts sync`.

A third, `paramify_issues` (the Wiz-style write-issues-back case), is an empty
stub.

## Why uploading is its own stage

Pushing to Paramify is **not** a fetcher concern. Fetchers collect and write JSON
to disk; a separate stage reads that output and talks to the API. Keeping them
apart buys the properties in [`design.md`](design.md#uploaders-as-a-separate-stage):
fetchers run with no Paramify connection at all (dev, testing, customer
dry-runs), a review/approval step can sit between collect and upload, re-uploading
a prior run is just pointing the uploader at an old directory, and a new kind of
write (issues, scripts) becomes a *new uploader* rather than a hack inside a
fetcher. Orchestration that chains the stages is customer-owned; `run_and_upload.sh`
at the repo root is example glue.

Both built uploaders share the same operational contract:

- **Auth** — `PARAMIFY_UPLOAD_API_TOKEN`, read source-agnostically (env, `.env`,
secret manager, CI). No token is privileged over another.
- **Base URL** — Paramify REST v0, default `https://app.paramify.com/api/v0`;
override with `PARAMIFY_API_BASE_URL` or `--config base_url`.
- **HTTPS-only token guard** — a non-https `base_url` is rejected before the token
is ever sent, so the bearer token can't leak over plaintext. `localhost` is the
only exception (local testing).
- **`--dry-run` / `--config` / `--json`** — preview read-only, point at a config,
or emit a machine-readable summary.

## The evidence-set identity model (shared)

Both uploaders target the same **evidence set** for a given fetcher, and they find
it the same way — so a script lands on the exact set its evidence does.

- Every `fetcher.yaml` carries an `evidence_set` block (`reference_id`, `name`,
`instructions`). This is **fetcher knowledge** — what the evidence is and how
it's collected — so it ships with the fetcher and is the shipped default the
runner folds into the envelope. Customers never edit it. (1 fetcher = 1 evidence
set.)
- At upload time the uploader **gets-or-creates** the set by `reference_id`.
Customers remap `reference_id` (and optionally `name`) **per program** in the
uploader `--config` `overrides.<fetcher_name>` — the two uploaders read the
*same* overrides, which is what keeps evidence and scripts on the same set.
- Control / solution-capability / validator linkage is **out of scope** and stays
Paramify-side. The `evidence_set` block deliberately does not carry it.

## `paramify_evidence` — attach evidence to sets

Reads a completed, enveloped `run-<timestamp>/` directory and, per evidence file:
gets-or-creates the evidence set by `reference_id`, then multipart-uploads the
artifact. Idempotent within a run (a re-run skips already-uploaded files rather
than duplicating them). Supports the shared flags above.

`paramify upload` takes an optional run directory (default: the latest run under
the manifest's `--output-dir`). Full setup — API-key permissions included — is in
[`../uploaders/paramify_evidence/README.md`](../uploaders/paramify_evidence/README.md).

## `paramify_scripts` — sync entry scripts, associate to sets

The `/scripts` API has **no stable external key** (only a server-assigned UUID)
and **no server-side versioning**. So this uploader can't get-or-create the way the
evidence uploader does; instead it **reconciles the tenant to the repo, GitOps
style**, using conventions the API *does* allow:

- **identity** — a marker written into the script's `description`:

```
paramify-fetcher: <fetcher name>
version: <fetcher.yaml version>
sha256: <sha256 of the entry file>
```

There is no server-side name/marker filter, so the tool lists all scripts once
and indexes them client-side by the `paramify-fetcher` line.
- **versioning** — the `fetcher.yaml` `version` is the update signal; git is the
history of record; the app just holds "current".
- **drift guard** — the `sha256` catches a code edit that forgot to bump the
version: **warn and skip by default**, `--force` to push it anyway.

### Action per fetcher

For every fetcher that declares an `evidence_set` and has a readable entry file
(`fetcher.py` / `fetcher.sh` — **shared modules are ignored**; only the entry
script is pushed):

| Action | When | Writes? |
|---|---|---|
| **create** | no script with this marker in the tenant | creates + associates |
| **update** | `fetcher.yaml` version changed | updates code + re-associates |
| **drift** | code changed but version did **not** | skipped (warns) unless `--force` |
| **no-op** | version *and* sha256 both match | nothing |

The script's **display name is the evidence set's `name`**. After a create or
update (or for every fetcher under `--reassociate`), the script is **CONNECTed**
to the fetcher's evidence set — which is get-or-created by `reference_id` exactly
as the evidence uploader does. The CONNECT is tolerant of an already-connected
script (the API has no pre-check), so re-runs are idempotent. Only `SCRIPT`
associations are automated.

### Flags beyond the shared set

- `--force` — push a script whose code drifted without a version bump.
- `--reassociate` — ensure the association for *every* fetcher, not just changed
ones (heals a script created without its link, or a partial earlier run).

Note: in `--dry-run` the summary **counts** stay zero (no actions are taken); the
plan is in the per-item results (`would_create` / `would_update` / `would_drift` /
`would_noop`), which `--json` and the human printer both show.

Full usage, config, and required tooling are in
[`../uploaders/paramify_scripts/README.md`](../uploaders/paramify_scripts/README.md).

## When to run which

- `paramify upload` — **every collection**, after a run, to push the evidence.
- `paramify scripts sync` — a **provisioning** step, when `fetchers/**` change
(a fetcher added, or its entry script / version bumped). Not on every
collection.
88 changes: 88 additions & 0 deletions framework/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -760,6 +760,94 @@ def upload_run(
)


# --------------------------------------------------------------------------- #
# Scripts — Paramify scripts sync facade (powers CLI; provisioning, not per-run)
# --------------------------------------------------------------------------- #

def _load_paramify_scripts_uploader(root: Path):
"""Load the source-tree scripts uploader without packaging uploaders/."""
path = Path(root) / "uploaders" / "paramify_scripts" / "uploader.py"
if not path.exists():
raise RuntimeError(f"Paramify scripts uploader not found at {path}")
spec = importlib.util.spec_from_file_location("paramify_scripts_uploader", path)
if spec is None or spec.loader is None:
raise RuntimeError(f"Could not load Paramify scripts uploader from {path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module


def scripts_sync_preflight(
root: Path,
config_path: Optional[Path] = None,
*,
dry_run: bool = False,
) -> dict:
"""Inspect scripts-sync readiness without making Paramify API calls."""
uploader = _load_paramify_scripts_uploader(root)
uploader.load_dotenv()
config = uploader.load_config(str(config_path)) if config_path else {}
paramify_cfg = config.get("paramify") or {}
base_url = (
paramify_cfg.get("base_url")
or os.environ.get("PARAMIFY_API_BASE_URL")
or uploader.DEFAULT_BASE_URL
)

errors: List[str] = []
fetcher_count = 0
for f in discover_fetchers(root).values():
if f.evidence_set and f.entry_path.exists():
fetcher_count += 1
if fetcher_count == 0:
errors.append("No fetchers with an evidence_set and a readable entry file to sync")

url_error = uploader._base_url_error(base_url)
if url_error:
errors.append(url_error)

token_present = bool(os.environ.get("PARAMIFY_UPLOAD_API_TOKEN"))
# A token is required to write; dry-run still wants one to diff the tenant,
# but tolerates its absence (it then reports every fetcher as a create).
if not token_present and not dry_run:
errors.append("PARAMIFY_UPLOAD_API_TOKEN is not set")

return {
"ok": not errors,
"base_url": base_url,
"fetcher_count": fetcher_count,
"token_present": token_present,
"dry_run": dry_run,
"errors": errors,
}


def scripts_sync(
root: Path,
config_path: Optional[Path] = None,
*,
dry_run: bool = False,
force: bool = False,
reassociate: bool = False,
on_event: Optional[Callable[[dict], None]] = None,
) -> dict:
"""Sync fetcher entry scripts to Paramify and associate them to evidence sets.

Fires sync_start / sync_item / sync_complete so front-ends can render
progress. Raises ValueError for setup errors; returns the uploader summary.
"""
uploader = _load_paramify_scripts_uploader(root)
config = uploader.load_config(str(config_path)) if config_path else {}
return uploader.sync_scripts(
root,
config=config,
dry_run=dry_run,
force=force,
reassociate=reassociate,
on_event=on_event,
)


# --------------------------------------------------------------------------- #
# Evidence — read produced run outputs (powers the TUI evidence browser)
# --------------------------------------------------------------------------- #
Expand Down
Loading
Loading