Skip to content

v0.4.0-beta

Pre-release
Pre-release

Choose a tag to compare

@21tmccauley 21tmccauley released this 18 Aug 18:35
· 686 commits to main since this release
36e4003

[0.4.0-beta] - 2026-08-18

Breaking

  • KnowBe4 group- and campaign-scoped fetchers now require config. The group
    and campaign names these three fetchers match on were hardcoded to one tenant
    (see Fixed below); they now come from config_schema and are required, so
    a manifest that ran them before will now fail paramify validate — and the run
    — until the values are wired:

    Fetcher New required config
    knowbe4_high_risk_training high_risk_groups, role_specific_campaigns
    knowbe4_developer_specific_training developer_groups, developer_campaigns
    knowbe4_security_awareness_training security_awareness_campaigns

    Each takes a comma-separated list of names exactly as they read in your tenant,
    set with paramify manifest set-config <fetcher> <key>=<value> or through the
    matching KNOWBE4_* env var. retraining_interval_days was added at the same
    time but defaults to 365 and needs nothing. paramify doctor <manifest> names
    any that are still missing.

  • KnowBe4 evidence changed shape. A metric the fetcher could not measure is
    now null rather than 0, and training_module_summary for an empty tenant is
    {} rather than null; a new results.config_resolution block records what was
    requested, what matched, and what the tenant actually has. Re-check any
    validator or query that asserts on those fields
    — one written against
    "completion_rate": 0 will no longer match, which is the point: 0 used to mean
    both "measured, and it is zero" and "could not measure", and only the first of
    those is a finding.

Added

  • Azure — 27 fetchers (new category). Identity and access (Entra ID MFA,
    privileged roles, conditional access), compute and AKS posture, storage and
    database encryption at rest, Key Vault, App Service, monitoring and backup.
    Python against the official azure-mgmt-* SDKs — no az CLI at runtime — and
    authentication is DefaultAzureCredential, so a managed identity, a service
    principal or a cached az login all work and none of them is a key file.
    Fanned out per subscription. Installs with pip install -e '.[azure]' (23
    packages), deliberately not folded into [all]: it is heavier than
    everything else here combined and only matters if you run Azure. Majors are
    pinned, because six breaking changes across azure-mgmt majors each produced
    wrong or empty evidence rather than an error. Credential setup, and the
    tenant-vs-subscription scoping that the Entra fetchers need, are documented in
    the six themed example manifests
    (examples/azure_identity_and_access.yaml
    and siblings); the category has no README.md of its own yet.
  • GCP — 19 fetchers (new category). Compute and GKE posture, IAM and service
    accounts, KMS, Cloud Storage and Cloud SQL encryption at rest, BigQuery, logging
    and monitoring, DNS. Python against the official google-cloud-* clients, no
    gcloud at runtime, authenticating through Application Default Credentials —
    again no key file. Fanned out per project; a target that omits project collects
    from the ADC default ("collect where deployed"). pip install -e '.[gcp]' (12
    packages), also out of [all]. Credential setup in
    fetchers/gcp/README.md.
  • Paramify — 3 FedRAMP 20x VER report fetchers (new category). Accepted
    Vulnerability Info (VER-RPT-AVI), the Vulnerability Detail Report, and the
    historical VER-activity snapshot, generated from Paramify issue data and fanned
    out per program. Shared logic lives in fetchers/paramify/_shared/ver_common.py.
    Report-period and workspace-wide knobs (cert_package_uri, api_base_url,
    http_timeout) are category config under platforms.paramify.config rather than
    per-target secrets, so one value per workspace is set once.
  • Optional secrets — required: false on a declared secret. The ambient
    identity case: aws, azure, gcp and k8s all authenticate through a credential
    chain whose preferred links (an instance role, EKS IRSA or Pod Identity, a
    managed identity, workload identity, SSO, a cached CLI login) hand over no
    secret at all, while the same chain also accepts static keys. Every declared
    secret used to be mandatory, so those keys could not be declared without
    breaking the deployments that supply none — which meant paramify describe and
    the TUI reported that an AWS fetcher needed no credentials whatsoever. They are
    now declared and marked optional: advertised, resolvable from a ${env:...} ref
    like any other secret (so the runner masks them out of captured output), and
    simply not injected when a manifest omits them.
  • Category-level secrets: in fetchers/_categories/<name>.yaml — credentials
    shared by every fetcher in a category, declared once instead of repeated. Azure's
    service principal is three env vars used identically by all 27 of its fetchers.
    A per-fetcher secret of the same name still wins, mirroring how config_schema
    already merges; contract.effective_secrets() is the one place both the
    describe/TUI surface and the runner resolve through, so what is advertised and
    what is demanded cannot drift apart. secrets[].description was added alongside,
    and is what explains why an optional credential is optional.
  • $FETCHER_STATUS_FILE — an explicit channel for why a collection failed.
    A fetcher that fails writes {"error": "...", "code": "..."} there and the runner
    puts it in the envelope as metadata.error / metadata.error_code (a new
    optional envelope field; the envelope stays additive, so schema_version is
    still 1.0). Previously metadata.error was the tail of stderr, which assumes the
    last thing a fetcher logged is why it failed — false for the 16 fetchers whose
    final line is "Evidence saved to …", so a failed collection reported a success
    message as its failure reason. The stderr tail remains the fallback, so nothing
    had to migrate. Paramify surfaces that field to whoever is triaging. (#24)
  • paramify programs — a new command group over the Paramify workspace.
    programs list shows each program's readable name next to its project UUID;
    programs target selects programs (interactively, by name/id, or --all) and
    writes them as fanout targets, filling in the shared config they need. The API
    identifies programs by UUID while people know them by name; this closes that
    gap without anyone copying a UUID by hand. The shared values (--cert-uri,
    --report-from) are shown on every interactive run with what the manifest
    holds today as the prompt default and a note of where it comes from: enter
    keeps it and writes nothing, typing over it updates the category value. So the
    same command adds a program and rolls the report window forward, and neither
    requires opening the manifest to see what the next run will carry. Entries that
    resolve to different values get no default — either one offered as the answer
    would misreport the other.
  • program_name — an optional target field on the Paramify VER fetchers. The
    fetcher uses it for its evidence filename and the uploader for the artifact
    title, so per-program artifacts read as … - Alpha Cloud Services rather than
    a bare UUID. A UUID prefix stays in the filename because program names are not
    guaranteed unique.
  • requires: on a category — fetchers/_categories/<name>.yaml gains a
    requires: block naming the external binaries and the pip distributions its
    fetchers need, and paramify doctor checks them. With a manifest it checks only
    the categories that manifest actually uses, so a GitLab-only run is never told
    to install the aws CLI or 23 Azure SDKs. Version pins stay in
    requirements.txt, which remains the single source of truth for versions; a
    category file names only which distributions are its own. The declarations are
    themselves tested (tests/test_category_requires.py): an import or a
    shelled-out binary that nothing declares fails the suite, because doctor's
    report is only as good as what the category files say.
  • paramify doctor --probe — authenticates against each cloud category
    (aws, k8s, azure, gcp) and reports which identity answered: caller ARN
    and account, Azure principal and subscriptions, GCP account and project.
    Without it doctor can only see whether variables are set, which is nearly
    meaningless for credential chains whose preferred links — IRSA, workload
    identity, managed identity, a cached CLI login — set no variable at all, so
    doctor reported a clean bill of health and the run failed on auth. It also
    answers "as whom", the more common silent failure: credentials that work but
    point at the wrong account produce a successful run full of empty evidence.
    Opt-in because it is the only part of doctor that touches the network, each
    probe runs in a subprocess under a hard timeout, and a failed probe is reported
    without failing the command.
  • paramify doctor --require-upload — makes a missing API token count toward
    the exit code. Upload readiness (token presence and the destination URL) is
    always reported; gating is opt-in because collecting evidence without uploading
    it is a legitimate workflow that should not fail a preflight.
  • paramify upload asks for the API token when none is set and it is safe to
    ask — a real terminal at both ends, no CI, not --json. Reaching the upload
    step means the collection is already paid for, and this is a failure a person
    can fix on the spot rather than by re-running the whole thing. The value is used
    for that run and written nowhere; persisting a credential is the user's call, so
    it prints how instead of guessing at a file. paramify run says the same thing
    up front, advisory only, so you learn it in the first second rather than the
    last.

Changed

  • paramify describe shows each field's env var and description. Both schemas
    said a field's description was shown there; it was not, so the two things
    needed to wire a field into a manifest — the env var it arrives in, and what it
    is for — were only readable by opening fetcher.yaml. It is also the only place
    an optional secret explains why it is optional.
  • paramify evidence and the TUI's evidence viewer print what the tenant wrote.
    Both rendered their JSON with the default ensure_ascii, so an em dash in a
    failure reason came back as \u2014 and any accented name was unreadable and
    unsearchable. The files on disk are unchanged.
  • The README's demo GIFs were re-recorded, and there are four now: a run and the
    evidence it wrote, building a manifest and preflighting it with doctor, the
    catalog and a fetcher's contract, and the TUI. The old three were rendered the day
    before the TUI's design language changed and two weeks before its Paramify tab was
    rebuilt, and predate azure/gcp/paramify entirely — tui.gif showed an application
    that no longer exists. They now also show the CLI's color. The recordings are made
    against a larger set of synthetic fetchers than the repo ships, so a run of your
    own is shorter than the one on film; what the frames show of the runner, the
    envelope and each command's output is what you get.
  • gitlab, knowbe4, okta and sentinelone categories now have a description.
    Four of thirteen listed as a bare name in paramify catalog, which is the one
    place someone browsing decides whether a category is what they want.
  • The tui extra pins textual>=8,<9 (was >=1.0,<2.0). The old range was not
    what anyone ran, and focus / Input behaviour differs enough across those lines
    that the TUI is not the same app on 1.x. tests/test_tui_keys.py (new) drives
    the real app through Textual's pilot to hold the key-and-focus contract: what
    each tab focuses, that the globals survive a repeat tab press, and that enter
    reaches an action wherever the footer says it does.
  • TUI: the footer hint bar lists esc (the only way out of a focused text
    field back to the shortcut keys — an Input consumes every printable key) and
    the Run tab shows enter/ctrl+r, since focus opens on the ▶ Run button and
    enter presses it.
  • Each VER report's _summary now carries a collection block (status + the
    API-failure ledger). /issues is the only call these fetchers make, so a
    failure yields empty report arrays; without this a failed report was
    indistinguishable from a genuinely clean one to anything reading the payload.
  • The uploader prefers a target's program_name over its opaque id when titling
    an artifact. Fetchers whose id is already readable are unaffected.
  • Every timestamp in a VER report is now emitted in one format — UTC, second
    precision, literal Z (2026-07-30T09:00:00Z). Values from the Paramify API
    (detectedAt, evaluationCompletedAt, the dueDate quoted in an overdue
    explanation) were previously passed through with the API's millisecond
    precision, so a single document mixed notations; they are normalized on the way
    in, and non-UTC offsets are converted rather than preserved. A report_from /
    report_to given as a bare date is expanded, with a date-only end reported as
    that day's last second (2026-06-30 → 2026-06-30T23:59:59Z) to match the
    window actually collected.
  • paramify doctor preflights the manifest, not just the environment. It now
    runs the same validation paramify validate does, so it cannot pass a manifest
    the runner will refuse: a misspelled use:, a missing required config key, a
    supports_targets fetcher with no targets[], or a declared secret the
    manifest never mapped. Scanning env-var references could not see any of those —
    a secret that was never mapped leaves nothing to scan, so it read as a clean
    bill of health and failed at run time. A manifest that cannot be parsed is now
    reported as a finding rather than raising a YAML traceback out of the CLI, and a
    valid one says so out loud, since silence on validity reads as "not checked".
  • Doctor checks secret values, not just that the variable exists. A
    whitespace-only value counts as missing: it reads as set to a presence check and
    then fails at the API, with the variable visibly present so nothing looks wrong.
    Values shaped like copy-paste artifacts — surrounding quotes, a trailing newline
    from $(cat file), a *_URL / *_URI / *_ENDPOINT with no scheme — are
    reported as warnings that never affect the exit code, because a legitimate value
    can look odd and a preflight that cries wolf stops being read.
  • The Paramify API token and base URL resolve in one place
    (framework/paramify_auth.py), which both uploaders and doctor read, so the
    answer to "which token, which workspace" cannot differ between the preflight and
    the upload. The destination is labelled production / stage / self-hosted and
    shown as loudly as the token: pointing at stage when you meant production does
    not error, the upload just succeeds into the wrong workspace.

Fixed

  • paramify validate no longer demands credentials the runner does not need.
    An optional secret could not pass validation: the runner skips one a manifest
    omits, but validate demanded every declared secret regardless — so as soon as the
    aws, azure and k8s categories declared their static keys (above), every manifest
    naming one of their fetchers would have failed preflight while running perfectly
    well. Validate also read a fetcher's own secrets rather than
    effective_secrets, so a category-declared credential was not checked at all and
    a manifest genuinely missing a required one passed validate and died in the run.
    Both halves are the same fix: resolve secrets the way the runner does, then skip
    the optional ones. No existing manifest newly fails — every category-level secret
    shipped today is optional.
  • KnowBe4: the three group- and campaign-scoped fetchers no longer report an
    unresolved config as a failing control. The group and campaign titles they match
    on were hardcoded to one tenant, so pointed anywhere else they emitted
    completion_rate: 0 and exited 0 — byte-identical to a tenant where the campaign
    resolved and genuinely nobody had trained. Two very different states, one output,
    and no assertion could tell them apart. The names now come from config_schema
    (high_risk_groups, role_specific_campaigns, developer_groups,
    developer_campaigns, security_awareness_campaigns, plus
    retraining_interval_days), and a name that matches nothing in the tenant is
    not a fetcher failure — one typo must not turn a whole nightly run red. The
    fetcher exits 0 and reports every metric it could not measure as null, never
    0, alongside a results.config_resolution block naming what was requested,
    what matched, and what the tenant actually has. null means "not measured"; 0
    still means "measured, and it is zero", so a genuine 0% remains a real finding.
    A config key that is never wired at all is caught pre-flight by
    paramify validate, since these are required.
  • KnowBe4: names are matched exactly rather than as substrings. A group
    configured as IT previously also swept in AUDIT and Legal-IT, inflating the
    high-risk population.
  • KnowBe4: config values reach jq as data (--args / $ARGS.positional)
    instead of being spliced into the filter text. A campaign title containing a
    quote or backslash produced a jq compile error before; making the titles
    customer-supplied would have turned that into a routine failure.
  • KnowBe4: all four fetchers assemble their evidence in one jq pass. Each
    record was previously appended by re-running jq over the growing output file,
    which was quadratic — 1500 enrollments took 69s and 3000 took over 120s, so a
    mid-size tenant blew the runner's 600s cap. 3000 enrollments now completes in
    about 3s. training_module_summary for an empty tenant is {} rather than
    null.
  • KnowBe4: a response that is not a JSON array (an error body returned with
    HTTP 200) is recorded as a failure instead of being treated as a page. Pagination
    previously looped forever on such a body, bounded only by the runner's timeout.
    Pagination also stops at a 1000-page cap, and printf '%s' replaces echo on
    every API response so a backslash in a title survives a non-bash shell.
  • TUI: pressing the number of the tab you are already on no longer clears
    focus. Assigning TabbedContent.active the value it already holds fires no
    TabActivated, so nothing re-homed focus after it was cleared — and because a
    page's bindings only resolve while focus is inside that page, every page
    shortcut (a/e/x, ctrl+r, j/k, the arrows) silently went dead until
    you pressed escape or a different tab.
  • TUI: ctrl+p on the Paramify tab runs Preview instead of opening Textual's
    command palette, which claims that key as a priority binding — checked ahead
    of the focused widget, so the page's own binding could never fire. p now does
    it too, mirroring the Manifest tab's preview key.
  • TUI: enter does what the footer promises on the two tables where it did
    nothing at all — on a run it drills into that run's evidence files (where enter
    opens one), and on a manifest row it opens the entry editor.
  • TUI: editing the manifest's output dir no longer loses the path. Textual
    selects an Input's value on focus, so the first keystroke replaced the whole
    path; and an edit never submitted with enter was silently reverted by the next
    rebuild. Focus no longer selects the value, and leaving the field commits it.
  • TUI: enter in a confirmation dialog now means No. Yes is composed first,
    so it took the default focus — on the dialogs that delete a manifest file,
    remove an entry, and upload to Paramify. y still confirms.
  • TUI: config set at the category level showed as unset on every entry that
    inherited it — the manifest screen read only the entry's own config block and
    had no notion of platforms.<category>.config. Both the detail pane and the
    summary count now render api.effective_config(), the same merge the runner
    performs, and show which layer each value came from.
  • Paramify VER fetchers: a pending or rejected RISK_ADJUSTMENT no longer
    reports finalDisposition: "Partially Mitigated" — mitigation now requires an
    accepted deviation, not an unapproved request.
  • An issue carrying neither poamId nor id no longer raises KeyError and
    kills the whole report.
  • PARAMIFY_HTTP_TIMEOUT is parsed at call time and falls back to the default on
    a malformed value, instead of aborting the run with a bare ValueError at
    import.
  • A timestamped report_to no longer over-includes up to a day beyond the
    declared reporting period.
  • PARAMIFY_REPORT_TO is now declared, so it can actually be set through a
    manifest (the runner passes only declared env vars).