Repository navigation
v0.4.0-beta
Pre-release[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 fromconfig_schemaand are required, so
a manifest that ran them before will now failparamify validate— and the run
— until the values are wired:Fetcher New required config knowbe4_high_risk_traininghigh_risk_groups,role_specific_campaignsknowbe4_developer_specific_trainingdeveloper_groups,developer_campaignsknowbe4_security_awareness_trainingsecurity_awareness_campaignsEach takes a comma-separated list of names exactly as they read in your tenant,
set withparamify manifest set-config <fetcher> <key>=<value>or through the
matchingKNOWBE4_*env var.retraining_interval_dayswas 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
nownullrather than0, andtraining_module_summaryfor an empty tenant is
{}rather thannull; a newresults.config_resolutionblock 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": 0will no longer match, which is the point:0used 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 officialazure-mgmt-*SDKs — noazCLI at runtime — and
authentication isDefaultAzureCredential, so a managed identity, a service
principal or a cachedaz loginall work and none of them is a key file.
Fanned out per subscription. Installs withpip 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 acrossazure-mgmtmajors 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 noREADME.mdof 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 officialgoogle-cloud-*clients, no
gcloudat runtime, authenticating through Application Default Credentials —
again no key file. Fanned out per project; a target that omitsprojectcollects
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 infetchers/paramify/_shared/ver_common.py.
Report-period and workspace-wide knobs (cert_package_uri,api_base_url,
http_timeout) are category config underplatforms.paramify.configrather than
per-target secrets, so one value per workspace is set once. - Optional secrets —
required: falseon 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 meantparamify describeand
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:infetchers/_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 howconfig_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[].descriptionwas 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 asmetadata.error/metadata.error_code(a new
optional envelope field; the envelope stays additive, soschema_versionis
still 1.0). Previouslymetadata.errorwas 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 listshows each program's readable name next to its project UUID;
programs targetselects 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 Servicesrather 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>.yamlgains a
requires:block naming the external binaries and the pip distributions its
fetchers need, andparamify doctorchecks 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 uploadasks 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 runsays the same thing
up front, advisory only, so you learn it in the first second rather than the
last.
Changed
paramify describeshows each field's env var and description. Both schemas
said a field'sdescriptionwas 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 openingfetcher.yaml. It is also the only place
an optional secret explains why it is optional.paramify evidenceand the TUI's evidence viewer print what the tenant wrote.
Both rendered their JSON with the defaultensure_ascii, so an em dash in a
failure reason came back as\u2014and 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 withdoctor, 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.gifshowed 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 inparamify catalog, which is the one
place someone browsing decides whether a category is what they want. - The
tuiextra pinstextual>=8,<9(was>=1.0,<2.0). The old range was not
what anyone ran, and focus /Inputbehaviour 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 — anInputconsumes every printable key) and
the Run tab showsenter/ctrl+r, since focus opens on the ▶ Run button and
enterpresses it. - Each VER report's
_summarynow carries acollectionblock (status + the
API-failure ledger)./issuesis 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_nameover 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, literalZ(2026-07-30T09:00:00Z). Values from the Paramify API
(detectedAt,evaluationCompletedAt, thedueDatequoted 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. Areport_from/
report_togiven 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 doctorpreflights the manifest, not just the environment. It now
runs the same validationparamify validatedoes, so it cannot pass a manifest
the runner will refuse: a misspelleduse:, a missing required config key, a
supports_targetsfetcher with notargets[], 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/*_ENDPOINTwith 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 validateno 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 ownsecretsrather 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: 0and 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 fromconfig_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 asnull, never
0, alongside aresults.config_resolutionblock naming what was requested,
what matched, and what the tenant actually has.nullmeans "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 arerequired. - KnowBe4: names are matched exactly rather than as substrings. A group
configured asITpreviously also swept inAUDITandLegal-IT, inflating the
high-risk population. - KnowBe4: config values reach
jqas 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
jqpass. Each
record was previously appended by re-runningjqover 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_summaryfor 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, andprintf '%s'replacesechoon
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. AssigningTabbedContent.activethe 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+pon 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.pnow does
it too, mirroring the Manifest tab's preview key. - TUI:
enterdoes 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 anInput's value on focus, so the first keystroke replaced the whole
path; and an edit never submitted withenterwas silently reverted by the next
rebuild. Focus no longer selects the value, and leaving the field commits it. - TUI:
enterin 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.ystill 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 ownconfigblock and
had no notion ofplatforms.<category>.config. Both the detail pane and the
summary count now renderapi.effective_config(), the same merge the runner
performs, and show which layer each value came from. - Paramify VER fetchers: a pending or rejected
RISK_ADJUSTMENTno longer
reportsfinalDisposition: "Partially Mitigated"— mitigation now requires an
accepted deviation, not an unapproved request. - An issue carrying neither
poamIdnoridno longer raisesKeyErrorand
kills the whole report. PARAMIFY_HTTP_TIMEOUTis parsed at call time and falls back to the default on
a malformed value, instead of aborting the run with a bareValueErrorat
import.- A timestamped
report_tono longer over-includes up to a day beyond the
declared reporting period. PARAMIFY_REPORT_TOis now declared, so it can actually be set through a
manifest (the runner passes only declared env vars).