qryx builds an organization-wide inventory of cryptography across code,
binaries, container images, live TLS endpoints, certificates, dependencies and
cloud KMS - normalizes it into a single cryptographic asset graph, scores
each asset for post-quantum and hygiene risk, and emits a standard CBOM
(CycloneDX). Open source, dev-first, built for mid-market. See
qryx-plan.md for the full design and roadmap.
The same service as its room on it-rat.com draws it, where the diagram sits next to a simulation you can scrub back and forth.
Qryx is the crypto plane of the TAIPANBOX agent-governance stack: it scans Agent Passports, agent-event hash-chains, and real crypto artifacts for post-quantum and hygiene risk.
flowchart TB
Agent["AI agent (any framework)"] -->|"LLM call (base-URL swap)"| TF["TokenFuse proxy: spend + enforcement"]
TF -->|"POST /v1/decide (PEP)"| WX["Wardryx: policy PDP"]
WX -.->|"allow / deny / hold"| TF
TF -->|"cheapest model, budget OK"| LLM[("LLM provider")]
TF -->|"CallRecords"| CL["TokenFuse Cloud: control plane, incidents, replay, evidence, kill-switch"]
VCX["Vouchryx: delegation proved, and endable"] -->|"short-lived token: act + cnf"| TF
TF -.->|"polls /v1/revocations"| VCX
VCX ==>|"delegation_issued / denied / revoked"| BUS
TF ==>|"agent-event NDJSON"| BUS{{"agent-event bus + Agent Passport"}}
WX ==> BUS
Agent -->|"web fetch"| SCX["Scopyx: governed web egress"]
SCX -->|"POST /v1/decide"| WX
SCX ==>|"web_fetch / web_blocked"| BUS
ENG["Engram: memory"] -->|"reflect via base_url"| TF
ENG ==> BUS
BUS ==> IDX["Idryx: identity graph, detectors, Agent-BOM"]
IDX ==>|"identity_finding"| BUS
BUS ==> QX["Qryx: crypto / PQC, passport + hash-chain scan"]
QX ==>|"crypto events"| BUS
BUS ==> VX["Verdryx: quality / drift"]
VX ==>|"quality events"| BUS
TF -->|"outcome-tagged traces"| VX
MX["Mockryx: pre-prod safety rehearsal"] -->|"hostile scenarios"| TF
MX ==>|"sim events"| BUS
BILL[("cloud, SaaS and model bills")] --> CC["CostCrew: the bill, worked by a crew of agents"]
CC ==>|"spend_spike / budget_threshold / crew moves"| BUS
BUS ==> TRX["Trailryx: the record plane, sealed and packed"]
BUS ==> HX["reads the log, mails you (heraldyx)"]
HX -->|"one mail, a view and never an action"| OPS["your mailbox"]
HX ==>|"alert_sent"| HJ[("heraldyx's own hash-chained journal, not this bus")]
YOU(["you, in a browser over your own tunnel"]) --> GX[["Genaryx: the console over all of it"]]
GX -->|"signed commands: the kill, an approval, a policy"| CL
GX -->|"signed commands"| WX
GX ==>|"console_command"| BUS
GX -.->|"reads it"| IDX
GX -.->|"reads it"| QX
GX -.->|"reads it"| VX
GX -.->|"reads it"| MX
GX -.->|"reads it"| ENG
GX -.->|"reads it"| SCX
GX -.->|"reads it"| CC
TFP["terraform-provider-taipan"] -->|"budgets + passports as code"| CL
ASG[["agent-stack-go: shared Go contract"]] -.->|imported by| IDX
ASG -.->|imported by| WX
ASG -.->|imported by| MX
ASG -.->|imported by| TFP
ASG -.->|imported by| HX
ASG -.->|imported by| QX
SPEC[["agent-passport: the spec"]] -.->|governs| BUS
- Consumes: Agent Passports and agent-event NDJSON (
qryx agentsreads each passport's declared attestation method and checksprev_hashchains), plus real crypto artifacts (code, binaries, TLS, cloud KMS). - Produces: NCSC/CNSA/CycloneDX crypto posture reports.
- Talks to: agent-passport (the passport and event schema it scans), fed by the same agent-event bus that TokenFuse, Wardryx, and Engram write to. Imports agent-stack-go for that contract rather than re-implementing it, like the other Go services in the stack.
The full stack is TokenFuse (spend), Wardryx (policy), Vouchryx (delegation), Engram (memory), Idryx (access), Qryx (crypto), Verdryx (quality), Mockryx (pre-prod), scopyx (governed web egress), CostCrew (the bill), Trailryx (the record) and heraldyx (the mail out), on the shared Agent Passport + agent-event contract (agent-stack-go / agent-passport), configured via terraform-provider-taipan and driven from Genaryx, the console over all of it.
Run the whole open stack locally with one command via stack-up; the stack's home on the web is it-rat.com.
Before any public launch, Qryx was run against 25,586 real Linux ELF binaries, a real container image,
and a live TLS endpoint: no crashes, and a live scan of api.anthropic.com correctly flagged its
certificate as quantum-vulnerable under the NCSC 2035 timeline. The run is
recorded in VALIDATION.md, first committed 2026-07-13.
Full write-up and the two real bugs live testing found (and fixed): VALIDATION.md.
The whole stack was deployed as a five-node k3s cluster on Hetzner, AWS and GCP
between 25 and 27 July 2026 (six clusters, all destroyed afterwards). The
manifests, the traps and the evidence are public in
stack-k8s. Qryx runs in two shapes there, and neither is a long-running service.
The console executes it to scan a path, so the binary ships inside the
console image rather than as its own pod. Separately it runs as a nightly
CronJob (crypto-trend, 03:17) over a mounted scan target, which is the
shape worth copying: crypto posture is a trend, and a scan that only happens
when somebody remembers to run it has no trend in it.
To be clear about scope: those runs verified the deployment shape and the service coming up correctly on three clouds. They did not scan anything larger than the cluster's own mounted path. The 25,586-binary scan in this file is still the load-bearing evidence for the scanner itself.
NIST standardized post-quantum algorithms in 2024 (FIPS 203/204/205) and
qryx grades against the CNSA 2.0 working dates: 2027 for new systems,
2030 for legacy migration and 2035 for completion (internal/report/cnsa.go).
The repository does not carry the NSA text those dates come from. "Harvest now, decrypt later" means data encrypted
with quantum-vulnerable crypto today can be captured now and decrypted once a
cryptographically relevant quantum computer exists - so the exposure is already
real. Migration starts with discovery. The planning note behind qryx says
organizations find 3-5× more cryptographic assets than expected
(qryx-plan.md); that is the note's own figure, not a measurement in this
repository. You can't migrate what you
can't see.
qryx is a pipeline: sources → scan engine → asset graph → outputs (the diagram at the top). Every connector emits findings in one model; they are deduplicated into a graph of unique assets, each carrying every place it occurs.
| Stage | What it covers |
|---|---|
| Sources | source code (Go · Python · JS · TS · Rust), Terraform/HCL, binaries (ELF · PE · Mach-O), container images (docker save / OCI), live TLS endpoints, PEM/x509 certificates, dependency manifests, cloud KMS (AWS KMS + ACM · GCP KMS · Azure Key Vault), AI-agent infrastructure (Agent Passport identity docs + agent-event NDJSON streams) |
| Scan engine | AST + parser detectors (goast, cryptocall, rust, certfile, tlsconfig, hardcoded, deps, terraform, aiusage), the binary/image/TLS/cloud connectors, and the risk classifier |
| Asset graph | one node per logical asset and risk class (algorithm + key size + risk class), deduplicated across all sources, with every occurrence attached |
| Outputs | CycloneDX 1.6 CBOM · human · HTML · CNSA 2.0 audit · NCSC PQC readiness · migration plan · signed evidence attestation · governance dashboard · JSON/Postgres snapshots · CI drift, severity & policy gates |
Every asset is scored against a post-quantum and hygiene model:
| Class | Examples | Why |
|---|---|---|
quantum-vulnerable |
RSA · ECC · DSA · DH | breakable by Shor's algorithm on a CRQC |
weak |
MD5 · SHA-1 · DES · RC4 · RSA<2048 | broken or deprecated primitives |
misconfig |
TLS 1.0/1.1 · insecure cipher suites | unsafe protocol settings |
expired |
past-due certificates | validity window elapsed |
hardcoded |
private keys in source/config | secrets embedded in the tree |
safe |
ML-KEM · ML-DSA · SLH-DSA | post-quantum (FIPS 203/204/205) |
Crypto found in test code (_test.go, testdata/, __tests__/, conftest.py,
*.spec.ts, and the rest of the language conventions) is scanned and counted,
then excluded from the production inventory: from the asset graph, the
compliance verdict, the policy gate, the --save baseline and every --format.
One line on stderr says how much was set aside and how much of it exists nowhere
else. --include-tests counts it as production instead. The classification is
not only path-based: Rust's in-file #[cfg(test)] blocks are recognized too,
so an inline test module inside a production .rs file is set aside just like
a _test.go file, while the production code around it still counts.
This is not cosmetic. Scanning this repository, 8 of 13 assets existed only
in test fixtures, and 21 of 40 occurrences were test code (measured on this
repository when the split was added; the numbers are in PROGRESS.md). A hardcoded-key
finding in a sibling repo reported three occurrences, two of which were fixtures
in tests/. Counting those inflates the number an operator is trying to drive to
zero and buries the findings they actually have to migrate.
Test findings are never dropped silently, because a hardcoded key in a fixture is still a key on disk. They are separated, not hidden.
"No findings" and "could not look" produce identical output on stdout, so the
scan counts what it could not examine and says so on stderr, next to the line
above: entries it could not read, files past the 4 MiB read cap, and files a
detector could not parse (Go source with a syntax error, a PEM block x509
rejects). A scan that walked a tree and examined none of it now looks different
from a scan of a tree with no cryptography in it. Nothing is printed when
everything was read, since a counter that prints zeros teaches the reader to
skip the line that will one day be non-zero.
qryx also inventories an operator's own use of LLM/AI provider SDKs and
endpoints across their own source tree: the aiusage detector. This is a
defensive, self-inventory capability, not a scanner of someone else's code or
systems. It exists so an operator can see, and govern, where their own systems
already talk to a model, which is a precondition for the EU AI Act's
code-inventory expectations around AI system usage.
It detects three things, each with a file and line:
- Dependency-manifest entries:
openai,anthropic,@anthropic-ai/sdk,@azure/openai,azure-ai-inference,azure-ai-projects,@ai-sdk/*,langchain/langgraph,google-generativeai/google-genai,google-cloud-aiplatform,vertexai,@google-cloud/vertexai,cohere,mistralai,litellm,ollama,groq,together,replicate,huggingface_hubingo.mod·requirements.txt·package.json·Cargo.toml·pom.xml.transformersis flagged too, but labeled "local model runtime" rather than a hosted LLM call, since it is HuggingFace's local inference library as much as an API client.boto3alone is never flagged: it is AWS's general-purpose SDK, not an LLM SDK. - Source-level imports/calls:
import openai,from anthropic import,from openai import AzureOpenAI,from azure.ai.inference import,import vertexai,from google.cloud import aiplatform,require('openai'),import Anthropic from '@anthropic-ai/sdk',from '@azure/openai',from '@azure-rest/ai-inference',from '@google-cloud/vertexai', and the Go import pathsgithub.com/sashabaranov/go-openai/github.com/anthropics/anthropic-sdk-go/cloud.google.com/go/vertexai/cloud.google.com/go/aiplatform, across Python, JS/TS and Go. - API endpoint literals:
api.openai.com,api.anthropic.com,*.openai.azure.com,*.cognitiveservices.azure.com,*.services.ai.azure.com,generativelanguage.googleapis.com,*-aiplatform.googleapis.com, the AWS Bedrock runtime endpoint,api.mistral.ai,api.cohere.ai/api.cohere.com,api.groq.com,api.together.xyz,openrouter.ai,api.perplexity.ai,api.replicate.com, anywhere they appear as a string literal, not only in code recognized by the two passes above.
A provider id names where the bytes go, not which model runs. Azure OpenAI
is azure-openai and Vertex AI is vertex, never openai and never google:
the same model reached through Azure is a request to Microsoft, and through
Vertex a request to a Google Cloud project rather than to the generative
language API. Where one package name contains another, the longer match wins
and the shorter one is dropped, so a tree depending only on @azure/openai is
never reported as calling OpenAI, and a tree depending on both is reported as
both.
It is informational, never a cryptographic risk. Every finding carries a
new ai-usage asset type (model.TypeAIModel), not one of the cryptographic
asset types, and an explicit info-severity, no-risk-class result. It shows
up in human/html output and in --save/--baseline drift like anything
else qryx finds, but it is deliberately excluded from the reports that are
specifically a cryptography grade (CycloneDX CBOM, the CNSA 2.0 audit, and
the NCSC PQC readiness verdict), so it can never render as a
"cryptographic-asset" component, inflate a compliance score, or flip a
migration verdict. It cannot trip --fail-on, --fail-on-new, or a
--policy gate by itself, by design: those all key on a finding's risk class,
and this one never carries one.
Honest limits. Detection is regex-over-content, the same approach
cryptocall uses for Python/JS/TS (a Go-AST resolution path, like goast
uses for crypto imports, is a plausible future upgrade but not justified for
this first version). That means it reliably catches declared imports, require
calls, dependency-manifest entries, and literal endpoint strings, but it
cannot see a dynamically constructed import name or an endpoint URL built at
runtime from configuration, and matching an import or an endpoint string does
not prove an LLM is actually called: the code might reference it in a
disabled branch, a comment, or a code path never exercised. This detector is
the static, code-side half of the inventory; confirming that a call actually
happens at runtime is a different source (idryx's eBPF network view), and
correlating the two (declared-but-never-called, or called-but-undeclared) is
a separate step that this detector does not perform.
--format ai-inventory writes the same findings as JSON. Every row names the
provider by a stable id rather than by the sentence a person reads, so a
consumer joins on anthropic rather than on Anthropic SDK (python), and the
id comes off the detector's own tables, so the whole tool has one row per
provider and no second copy anywhere downstream.
{
"schema": "qryx.ai-inventory/v1",
"entries": [
{ "provider": "anthropic", "role": "provider", "label": "Anthropic SDK (python)",
"occurrences": [ { "file": "src/agent.py", "line": 2, "evidence": "import anthropic" } ] }
],
"limits": [ "..." ]
}role is one of three, and the distinction is what keeps the document honest:
provider, a named provider the code can reach.framework, where the code reaches a model through LangChain, LiteLLM or a similar indirection. Itsprovideris empty on purpose: the tree does reach a model, and which one is chosen by configuration a text scan cannot read. An empty id there is a fact, not a gap.local-runtime, weights loaded in-process or a model server on the operator's own machine, where nothing leaves.
limits is in every document, including an empty one. An inventory that finds
nothing looks exactly like a tree that uses no AI, and only those sentences say
otherwise.
This is what lets the code-side answer meet the other two the stack already
holds: what an agent's Passport declares it uses (models, SPEC 4.5), and
what idryx observed it reach. Three answers to one question, from three
places that cannot see each other, and the interesting rows are the ones where
they disagree.
Snapshot the asset graph, then fail the build when a new weak or quantum-vulnerable asset is introduced - the "don't add new weak crypto" gate.
qryx scan --save base.json <path> # 1. baseline
qryx scan --baseline base.json --fail-on-new high <path> # 2. diff → exit 2 on new high-riskBoth drift gates (--fail-on-new, and --policy together with
--policy-new-only) read their verdict out of the comparison against the
baseline. If the baseline cannot be loaded there is no comparison, so qryx
exits non-zero and names the path it could not find rather than gating on an
empty set of new assets and reporting a pass. A typo in a CI path, a cache miss
or a first run on a new branch would otherwise turn a blocking gate into a green
build, and nothing on stdout would say so.
The first run is the exception, and it has to be stated:
qryx scan --baseline base.json --fail-on-new high --allow-missing-baseline <path>--allow-missing-baseline restores skip-and-continue: the missing baseline is
reported on stderr and the gate passes. Use it for the run that creates the
baseline, not as a permanent flag in the pipeline, since it opts back out of the
thing the gate is for. --baseline on its own is unaffected: with no gate
depending on it, a missing snapshot stays a warning and an exit 0.
qryx is a security tool, so its own build is held to the standard it audits
others against. A dedicated security CI job runs govulncheck
against every dependency and gosec static analysis on every push to main;
the results are in each run's log, not in this file. Every job in CI pins the
same toolchain go.mod asks for (1.27.2), because until 1 August 2026 the workflows said 1.26.5 while
go.mod said 1.27, and Go quietly downloaded the newer toolchain rather than
failing: the pin said one thing and the build did another. Every gosec finding was either fixed (scoped file reads via
os.Root, tightened file/dir permissions, explicit handling of best-effort
cleanup errors) or is a deliberate pattern annotated inline with a
#nosec Gxxx -- reason comment on the exact offending line - e.g. qryx tls's
InsecureSkipVerify (it inspects TLS posture, it doesn't trust it) - never a
blanket CI exclude.
Prebuilt binaries (Linux, macOS, Windows) are published on the
Releases page for every v* tag,
with a SHA256SUMS file for verification:
The asset names carry no version, so releases/latest/download/<name> is a
permanent address for the current build. You never look up a version number,
and a link to one of these does not rot.
P=$(uname -s | tr A-Z a-z)_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
U=https://github.com/TAIPANBOX/qryx/releases/latest/download
curl -fsSLO $U/qryx_$P.tar.gz
curl -fsSLO $U/SHA256SUMS
sha256sum -c SHA256SUMS --ignore-missing
tar -xzf qryx_$P.tar.gz
./qryx_$P/qryx versionThe version is still there, in the binary rather than in the filename:
qryx version prints the tag it was built from. That is the harder of the two
places to fake, since anything between us and you can rename a file. It
prints qryx <tag> and exits 0.
Or build from source (Go 1.27; the pinned go1.27.2 toolchain auto-downloads on first build):
make build # → ./bin/qryxDownloading our binary and building it yourself are not a choice between trust and effort. They produce an identical file, so you can take the fast path and still have somebody verify it later.
# ours: the latest release, at an address that never changes
mkdir -p /tmp/verify && cd /tmp/verify
curl -fsSLO https://github.com/TAIPANBOX/qryx/releases/latest/download/qryx_darwin_arm64.tar.gz
tar -xzf qryx_darwin_arm64.tar.gz
TAG=$(./qryx_darwin_arm64/qryx version | awk '{print $2}') # what those bytes claim to be
# yours, from a clean tree at exactly that tag
cd /path/to/your/qryx
git checkout "$TAG"
git status --porcelain # must print nothing at all
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -trimpath \
-ldflags "-s -w -X main.version=$TAG" -o /tmp/verify/mine ./cmd/qryx
sha256sum /tmp/verify/mine /tmp/verify/qryx_darwin_arm64/qryx
# macOS ships shasum -a 256 rather than sha256sum, and this example builds for
# darwin, so that is probably the one you wantTwo identical digests, and note what the recipe does NOT contain: a version number. It reads the tag out of the binary it is checking, so the instruction cannot go stale and the check is stronger for it, since a build that lies about its own version now fails the comparison it invited.
Build from a clean tree, and do not unpack our archive inside it. Go stamps
vcs.modified into the binary, and one untracked file anywhere in the
checkout flips it to true, which changes the bytes. The release is built from
a clean CI checkout, so it carries vcs.modified=false. Unpacking the download
into the repository before building is enough to break the comparison on its
own. git status --porcelain is in the recipe for that reason and is not
decoration.
The same trap has a second door. Building from a git archive extraction or
a detached git worktree leaves Go unable to read the VCS at all, so it records
the module as (devel) rather than the tag. Both doors lead to a binary that
legitimately differs from the release, and the difference looks enormous because
a version string one byte shorter shifts everything after it. It is one field,
not a different program.
Compare the binaries, not the archives. SHA256SUMS on the release page
lists the .tar.gz and .zip files, and it answers a different question: did
your download arrive intact. It cannot answer this one, because tar and
gzip stamp times into the archive, so the archive is not reproducible even
when every byte of the binary inside it is.
Measured on 5 August 2026, against the real published artifact rather than in
principle: qryx_v0.3.0_darwin_arm64 from the Releases page (v0.3.0 was the last release
whose asset names carried a version), built on a Linux runner cross-compiling to darwin/arm64, and a local build of that same tag on
macOS are both
0864315d02b8580b56cb997e31b2bcef1bb804f4713981c0fae424ace3303c2b
Different machine, different host OS, identical bytes.
Three flags make that work, CGO_ENABLED=0, -trimpath and -s -w, and losing
any one of them would break it silently: the build would still succeed and
only somebody trying to verify us would ever find out. So CI builds the same
source in two directories of different lengths on every push and refuses if a
byte differs (scripts/reproducible-build.sh).
What this does not claim: a different Go version will not give the same bytes.
go.mod pins the toolchain, and a digest is only meaningful next to the
compiler that produced it.
Maintainers: a release is cut automatically by CI on
git tag vX.Y.Z && git push --tags.
Every release is signed keyless with Sigstore and carries a build-provenance
attestation and an SBOM. With cosign and gh installed:
tag=<tag>
cosign verify-blob --bundle SHA256SUMS.sigstore.json \
--certificate-identity "https://github.com/TAIPANBOX/qryx/.github/workflows/release.yml@refs/tags/${tag}" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com SHA256SUMS
sha256sum -c SHA256SUMS
gh attestation verify qryx_darwin_arm64.tar.gz -R TAIPANBOX/qryxThe image:
cosign verify ghcr.io/taipanbox/qryx:<tag> \
--certificate-identity-regexp '^https://github.com/TAIPANBOX/qryx/\.github/workflows/release\.yml@refs/tags/v' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
gh attestation verify oci://ghcr.io/taipanbox/qryx:<tag> -R TAIPANBOX/qryxReleases through v0.3.1 have none of this; the tags say so.
make build
qryx scan <path> # static scan of a code tree
qryx scan --format cbom <path> # CycloneDX 1.6 CBOM (JSON)
qryx scan --format html <path> > report.html # self-contained web report
qryx scan --format cnsa <path> # CNSA 2.0 compliance audit (JSON)
qryx scan --format cnsa-html <path> > cnsa.html # CNSA 2.0 audit (HTML)
qryx scan --format ncsc <path> # NCSC PQC migration readiness, 2028/2031/2035 milestones (JSON)
qryx scan --format ncsc-html <path> > ncsc.html # NCSC readiness (HTML)
qryx scan --format evidence <path> > evidence.json # tamper-evident compliance attestation
qryx scan --format evidence --sign-key key.pem <path> > evidence.json # ...signed (ed25519/ECDSA/ML-DSA)
qryx verify-evidence --ci --signer sha256:... evidence.json # verify a signed attestation (CI: refuses without a pinned signer)
qryx scan --format dashboard <path> > dashboard.html # one-page governance dashboard
qryx scan --format ai-inventory <path> # where this tree reaches an AI model (JSON)
qryx scan --save-evidence trail.jsonl <path> # append a dated compliance record
qryx trend trail.jsonl # show the compliance-score history
qryx trend --html trail.jsonl > trend.html # ...as an SVG chart
qryx trend --fail-on-regression trail.jsonl # exit 3 if the score dropped (CI)
qryx scan --format migration <path> # risk-prioritized migration plan (JSON)
qryx scan --fail-on high <path> # exit 2 if any finding >= high (for CI)
qryx scan --policy cnsa <path> # enforce a crypto policy; exit 3 on violation
qryx scan --policy .qryx-policy.json <path> # ...or a custom JSON policy
qryx scan --policy cnsa --baseline base.json --policy-new-only <path> # fail only on NEW violations
qryx fix <path> # show safe code patches as a unified diff
qryx fix --write <path> # apply them in place (e.g. raise RSA key size)
qryx fix --open-pr <path> # apply, branch, commit and open a GitHub PR (git+gh)
qryx tls example.com:443 # probe a live endpoint's TLS posture
qryx bin /usr/bin/openssl # crypto in a binary (ELF/PE/Mach-O)
docker save app:latest -o img.tar && qryx image img.tar # scan a container image
qryx aws --region us-east-1 # inventory AWS KMS keys + ACM certs
qryx gcp --project my-project # inventory GCP Cloud KMS key versions, every location
qryx gcp --project my-project --location europe-west1 # ...one location only
qryx azure --vault-url https://myvault.vault.azure.net/ # inventory Azure Key Vault
qryx agents ./passports # inventory AI-agent attestation crypto + event-stream integrity
qryx agents --events events.ndjson ./passports # ...and append findings as agent-event NDJSON
qryx scan --save base.json <path> # snapshot the asset graph
qryx scan --baseline base.json <path> # report drift vs the baseline
qryx scan --baseline base.json --fail-on-new high --allow-missing-baseline <path> # ...first run, before the baseline existsFlags must precede the positional path/targets (
qryx scan [flags] <path>).qryx tlsconnects only to the exacthost:portarguments you pass - no port ranges, no host discovery. Probe only endpoints you are authorized to test.
Run against the bundled fixtures with make scan.
Code scan (qryx scan) - 9 detectors:
| Detector | Covers |
|---|---|
goast |
crypto usage in Go via AST import resolution (no regex false positives) |
cryptocall |
crypto API usage in Python / JS / TS source. Comments are stripped before matching in both languages, and string literals too for the Python identifier patterns (RSA, AES, DES), so a comment saying "migrate off RSA" is not a use of RSA. The JS patterns keep literals, because node names the algorithm inside one (createHash('md5')) |
rust |
crypto primitives in Rust: the ring/aws-lc-rs constants, the RustCrypto crate paths, and a hand-written implementation naming its own type. Comments and string literals are stripped before matching, so a doc comment explaining that ECDSA is quantum-vulnerable is not counted as a use of ECDSA |
certfile |
PEM certificate parsing (algorithm, key size, expiry) |
tlsconfig |
legacy TLS/SSL in code and nginx/apache config |
hardcoded |
private keys embedded in source/config |
deps |
crypto libraries in dependency manifests, inventoried under their own names and marked informational: a manifest says a library is present, never which primitives the code calls, so a dependency is never reported as a quantum-vulnerable asset |
terraform |
key material in HCL via the hashicorp/hcl parser (tls_private_key, aws_kms_key, azurerm_key_vault_key, google_kms_crypto_key) |
aiusage |
the operator's own LLM/AI provider SDKs, imports and endpoint literals, informational and never a crypto risk (see AI usage inventory) |
TLS probing (qryx tls) - negotiated TLS version, insecure cipher suites,
and the leaf certificate's public-key algorithm, size and expiry.
Binary scanning (qryx bin) - ELF/PE/Mach-O via debug/elf|pe|macho,
mapping needed crypto libraries and imported symbols to assets: both the
legacy flat OpenSSL API (MD5_*, RSA_*, …) and OpenSSL 3.x's EVP_*
interface (EVP_aes_*, EVP_sha256, EVP_PKEY_CTX_set_rsa_*, …), since
modern OpenSSL 3.x builds call crypto almost exclusively through EVP_*, so
both are resolved. Symbol/library based, not string scraping; low false
positives.
Known blind spot: statically-linked crypto. Detection is primarily via the
dynamic import table (.dynsym / needed libraries); a statically-linked
OpenSSL/BoringSSL/libsodium binary, a Rust binary using ring/rustls, or a
Go binary with its crypto compiled in has none of that, and used to scan as
"clear". For ELF, a non-stripped static binary now also falls back to the
full symbol table (.symtab), so its crypto symbols are still caught. A
stripped static binary has no .symtab either, and PE/Mach-O have no
equivalent fallback implemented yet: both stay invisible to this scanner
regardless of stripping. Treat a "clear" qryx bin result on a statically-
linked binary as limited assurance, not proof there is no crypto in it.
Container images (qryx image) - extracts a local image tarball
(docker save / OCI) with stdlib tar/gzip, hardened against path traversal and
tar bombs, then runs the code and binary scanners over the layers. Layers are
streamed rather than buffered, so layer size is not a limit on what can be
scanned, and an image that cannot be extracted is an error naming that image
rather than an empty finding list and exit 0. Individual files inside a layer
above the 32 MiB per-file cap are skipped, counted, and reported on stderr:
skipped, because half a binary scans as a binary with no crypto in it.
AWS cloud (qryx aws --region <r>) - KMS keys (by key spec) and ACM
certificates (algorithm + expiry) via the default credential chain. The SDK sits
behind an interface seam so the connector logic is unit-tested without an account.
GCP cloud (qryx gcp --project <id>) - Cloud KMS key versions mapped by
algorithm (RSA/EC/AES/HMAC, and PQC ML-DSA/ML-KEM/SLH-DSA as safe) via
Application Default Credentials, behind the same lister seam. Every location
by default (locations/-, the Cloud KMS wildcard), because key rings are
overwhelmingly regional; --location europe-west1 narrows it to one. Until 5
August 2026 the default was global, so a plain qryx gcp --project X
inventoried one location out of dozens and reported the near-empty result as a
clean one.
Azure cloud (qryx azure --vault-url <url>) - Key Vault keys mapped by JSON
Web Key type (EC/EC-HSM → ECDSA, RSA/RSA-HSM → RSA with size from modulus,
oct/oct-HSM → AES) via DefaultAzureCredential. Expired keys are flagged
separately.
A cloud policy that grants list but not get on individual resources is an
ordinary configuration: Key Vault's keys/list without keys/get, an AWS policy
with kms:ListKeys and no kms:DescribeKey. All three connectors used to
return the first such error and end the whole inventory, so an account they
could partly read produced nothing at all. They now skip the resource they
cannot read and carry on, and every skipped resource is counted and named on
stderr, because nine keys out of ten reported as though they were all of them
is the worse failure. A listing call that fails is still fatal: without it
there is no inventory to be partial about.
AI-agent infrastructure (qryx agents <path>) - inventories the
agent-governance stack's own trust surface: Agent Passport attestation.method
(mTLS/SPIFFE → certificate-based, enclave key → hardware-backed, OIDC
→ token-based, none → a misconfig finding). The method is what the passport
declares: qryx does not check the certificate, SVID or enclave key behind it,
and every finding says "declares" and, for a named mechanism, "not verified" and agent-event NDJSON
prev_hash chains (every event carries a distinct sha256 prev_hash → a
sha256 hash asset; any event missing one, or the same value repeated across
events, → a misconfig finding). The chain check has two passes: a structural
pass confirms every event is linked and no hash is suspiciously reused, and a
stream that passes it is then checked by agent-stack-go's event.VerifyChain,
which recomputes each event's RFC 8785 canonical hash and compares it with the
prev_hash of the event after it. Passport/event files are told apart by their schema field, not
extension; malformed files are counted and skipped, never fatal. Identity and
privilege stay Idryx's job - this connector stays strictly on the crypto axis.
Agent-event export (--events <path>, internal/exporter): appends
findings, drift, policy violations, and signed-evidence records as agent-event
NDJSON (taipanbox.dev/agent-event/v0.1, source: "qryx", per agent-passport
SPEC.md §6.2's crypto_finding/crypto_drift/policy_violation/
evidence_signed types), the producer half of qryx's Passport-awareness (it
was already a consumer via qryx agents resolving agent_id as an evidence
subject). Opt-in, fail-open, and agent_id is never fabricated: only findings
carrying a real subject emit at all, which today means qryx agents'
passport findings specifically -- the vast majority of qryx's other sources
(code, binaries, TLS, cloud KMS) have no agent concept, so --events is a
no-op for them by design, not an oversight. crypto_drift and
policy_violation reuse the exact same --baseline/--policy results the
human report prints, filtered to the subset with a real agent_id;
evidence_signed fires once per distinct agent covered by a signed
--format evidence document, not once per finding. Events now carry the
SPEC §6.5 prev_hash chain; verify a stream with agent-conform -chain <file>.
Asset graph - findings from every source collapse into one node per logical
asset and risk class, deduplicated across files and sources: the same
algorithm and key size but two orthogonal risks (say, a certificate that is
both expired and quantum-vulnerable) become two nodes, not one, so neither
risk is silently dropped. The CBOM emits one CycloneDX component per node with
all its occurrences; the human report shows asset-level counts (one RSA row
with 112 occurrences, not 112 rows, though a physical asset carrying two risk
classes shows up as two rows, one per risk); --format html renders the same
graph as a static page.
Persistence - behind a Store interface with two backends: a JSON file (any
path) and Postgres (a postgres:// URL), persisting the graph into
normalized scans/assets/occurrences tables.
qryx scan --save 'postgres://user:pass@host:5432/db' <path>
qryx scan --baseline 'postgres://user:pass@host:5432/db' --fail-on-new high <path>Compliance & governance reports - five reports form a compliance pack for regulated orgs, all computed from the same asset graph and the same risk classification so they can never disagree with one another:
CNSA 2.0 audit (--format cnsa/cnsa-html) - classifies every asset
against the NSA's CNSA 2.0 suite: ML-KEM/ML-DSA/SLH-DSA and AES-256/SHA-384+
are compliant; RSA/ECDSA/ECC/DSA/DH are non-compliant with a 2030 migration
deadline; MD5/SHA-1/DES/3DES/RC4 and sub-floor keys are non-compliant
immediately; expired certificates, hardcoded keys and TLS misconfig are
flagged as issues. Everything else is not assessed (below). Reports all
four counts, a percentage score, and a per-asset remediation action, sorted by
deadline urgency. --policy cnsa enforces the same standard as a CI gate (see
Policy enforcement below); this report is the audit view, in JSON or HTML.
An algorithm with no CNSA 2.0 rule in qryx is reported as not-assessed, its
own status, and never counted as compliant. SHA-256 is not on the CNSA 2.0
list; bcrypt, HMAC and ChaCha20 are outside the suite; X509, OIDC and
enclave-key are the attestation pseudo-assets qryx agents emits; and any
algorithm the classifier has never seen lands here too. Until 5 August 2026 all
of them were graded "compliant" with "No CNSA 2.0 restriction identified", so a
scan of cryptography this tool does not recognize scored 100%, and that number
is what --format evidence signs and what qryx trend --fail-on-regression
gates on.
Not-assessed assets stay in the denominator of the score. Leaving them out
would flatter exactly the scan that deserves it least, the one full of
unrecognized crypto; counting them as compliant did that already. So the score
is the compliant share of everything graded, and all four counts are printed
next to it in both JSON and HTML, because a reader who cannot see how much was
never assessed cannot judge the percentage. Scores from before this change are
not comparable with scores after it: on this repository's own tree the figure
moved from 16% to 0%, and on the qryx agents fixtures from 50% to 0%, with no
change to the cryptography being scanned.
NCSC PQC readiness (--format ncsc/ncsc-html) - tracks the same graph
against the UK NCSC's three-milestone PQC migration timeline: complete
discovery by 2028, migrate the highest-priority systems by 2031, migrate
everything by 2035. Each milestone gets a deterministic
on-track/at-risk/not-started verdict - 2028 is at-risk if any
quantum-vulnerable asset has no recognized migration target; the 2031
"highest-priority" subset is quantum-vulnerable and either
externally-facing (seen via a live TLS probe or an AWS ACM certificate) or
long-lived data (an encryption/key-exchange primitive, i.e. exposed to
harvest-now-decrypt-later) - the exact predicate is embedded as a criteria
string in both outputs, so the report documents its own rules. The migrated
count is honestly reported as 0 within a single scan (qryx doesn't persist
remediation state across runs); track real progress with --baseline drift
or the evidence trail (--save-evidence / qryx trend) below.
Migration plan (--format migration) - scores each non-compliant asset's
agility (how hard it is to change: high for managed KMS keys you rotate via
API, medium for config/cert/dependency changes, low for code that needs a
redeploy) and emits a risk-prioritized plan. Each entry carries a recommended
PQC/strong target (RSA→ML-DSA/ML-KEM, ECDSA/DSA/Ed25519→ML-DSA, MD5/SHA-1→SHA-256,
etc.), a rationale and the occurrence locations. Quick wins - high-agility,
high/critical severity - are counted in the summary. Works on any source,
including cloud: a KMS RSA key reports high agility, the same algorithm in
source reports low. An AES key whose size the scan never established is
listed rather than left out, because an unread size is not a 256-bit one; its
rationale says the size is the missing fact and the locations say where to go
and read it.
Remediation (qryx fix) - turns findings into reviewable source patches,
but only for transforms that are provably safe. Today that is raising a
sub-floor RSA key size - in Go (rsa.GenerateKey(rand, 1024) → 3072) and in
Terraform (rsa_bits = 1024 → 3072), configurable via --min-rsa-bits: a
single integer-literal change that stays valid and compiles. By default it
prints a unified diff; --write applies it in place. Algorithm swaps
(MD5→SHA-256) and hybrid schemes change semantics and break downstream
consumers, so they stay as migration guidance and are never auto-applied.
With --open-pr the fix is applied on a fresh branch and opened as a GitHub
pull request (via git + gh), with the rationale and diff in the PR body -
guarded by a clean-working-tree check so it never mixes in unrelated edits.
Policy enforcement (--policy) - gate CI on a declarative crypto policy.
Pass a builtin (cnsa) or a JSON file; qryx evaluates the deduped asset graph
and, on any violation, prints a report to stderr and exits 3 (distinct from
--fail-on's severity gate, exit 2, so CI can tell them apart). The builtin
cnsa forbids weak algorithms (MD5, SHA-1, DES, 3DES, RC4, DSA), requires
RSA ≥ 3072, and rejects hardcoded keys / expired certs / TLS misconfig;
quantum-vulnerable assets are opt-in (forbidQuantumVulnerable) since their
CNSA deadline is 2030. A custom policy is plain JSON:
{
"name": "example-strict",
"forbidAlgorithms": ["MD5", "SHA-1", "DES", "3DES", "RC4", "DSA"],
"minRsaBits": 3072,
"forbidQuantumVulnerable": false,
"forbidHardcoded": true,
"forbidExpired": true,
"forbidMisconfig": true,
"maxSeverity": "medium"
}--policy writes only to stderr, so --format cbom/html output on stdout
stays valid. Add --baseline <snapshot> --policy-new-only to gate on drift -
only assets new since the baseline are evaluated, so a clean policy can be
adopted on a legacy codebase without blocking on pre-existing debt while still
failing any newly introduced weak crypto. A baseline that cannot be loaded is an
error under this combination rather than an empty set of new assets and a pass
(see Drift detection in CI and
--allow-missing-baseline).
Evidence export (--format evidence) - a self-describing, tamper-evident
compliance attestation for audit/GRC: tool + version, UTC timestamp, scan root,
a CNSA 2.0 compliance summary (compliant / non-compliant / issues, score, and a
breakdown by severity), the per-asset records, and a sha256: content digest
over the document with the digest field blanked. A verifier recomputes the hash
the same way to confirm the artifact is unmodified - integrity without key
management. Reuses the same CNSA classification as --format cnsa, so the two
never disagree. Commit evidence.json as a CI artifact for a dated audit trail.
Pass --sign-key <pkcs8.pem> to add a detached signature over the digest
(ed25519, ECDSA P-256, or ML-DSA (FIPS 204) -- stdlib only, no cosign
dependency), embedding the public key so the artifact is self-verifying:
openssl genpkey -algorithm ed25519 -out key.pem
qryx scan --format evidence --sign-key key.pem ./src > evidence.json
qryx verify-evidence --ci --signer sha256:... evidence.json # VERIFIED (ed25519, key sha256:...) or exit 1
# post-quantum: ML-DSA-44/65/87, all three levels accepted -- seed-only
# encoding required (Go's x509 parser doesn't support OpenSSL's default
# seed+expanded PKCS#8 form)
openssl genpkey -algorithm ML-DSA-44 -provparam ml-dsa.output_formats=seed-only -out mldsa-key.pem
qryx scan --format evidence --sign-key mldsa-key.pem ./src > evidence.json
qryx verify-evidence --ci --signer sha256:... evidence.json # VERIFIED (ml-dsa-44, key sha256:...) or exit 1verify-evidence recomputes the digest, confirms the document is unmodified,
and checks the signature against the embedded key. That proves the document is
consistent, not who signed it: the key travels inside the document, so anyone
who rewrites it and signs again with their own key produces one that passes.
Pin the key you expect with --signer sha256:... (the fingerprint it prints)
to make the check answer who signed it. Without --signer the result is
reported as not verified: no signer pinned, never as VERIFIED. In a CI
step, pass --ci as well: it refuses to pass unless --signer is set, so the
step cannot succeed on a forgery. ed25519 and ECDSA P-256 remain
classically strong but quantum-vulnerable; ML-DSA is the quantum-resistant
option, requiring Go 1.27+ (crypto/mldsa).
Governance dashboard (--format dashboard) - one self-contained HTML page
for a security lead: the CNSA compliance score, the risk profile by severity,
the evidence integrity digest, and the top remediation priorities (the
compliance × agility ranking - which assets to fix first and what to migrate
them to). It aggregates the CNSA, migration and evidence views that are
otherwise separate; numbers come from the same computations, so it can't
disagree with them.
Evidence trail (--save-evidence + qryx trend) - append one compact,
digest-stamped record per run to a JSON-Lines trail (date, score, all four
status counts including not-assessed, the total, and the integrity digest).
qryx trend <trail> renders the history and the latest score delta (improved /
regressed / unchanged), so a team can prove posture over time and catch
regressions. --html renders the history as a self-contained SVG line chart;
--fail-on-regression exits 3 when the latest score is below the previous run,
turning the trail into a CI monitor. Records share the same numbers and digest
as --format evidence.
The not-assessed count is carried because the score alone does not say what it
is a percentage of: two runs can both read 50% while one graded its whole
inventory and the other graded half of it, and a delta between those two is not
a change in posture at all. qryx trend prints the count per record and states
the latest ungraded share against its total when there is one. Records written
before this count existed read fine and report 0, which is what they meant.
The trail works with a file path or a postgres:// URL (same backends as
--save/--baseline); an evidence table created by an earlier version gains
the new column on the next connection, so no manual migration is needed:
qryx scan --save-evidence 'postgres://user:pass@host:5432/db' <path>
qryx trend 'postgres://user:pass@host:5432/db'Phases 0-4 complete, including post-quantum evidence signing:
- static code scan · TLS probing · binary scanning (ELF/PE/Mach-O) · container images
- cross-source CBOM asset graph · JSON/Postgres persistence · drift detection · CI gate
- human / CBOM (CycloneDX 1.6) / HTML reports -- all CI-gated
- Phase 2 cloud KMS -- AWS, GCP and Azure done; owner-mapping; CNSA 2.0 audit report
- Phase 3 -- crypto-agility scoring (
--format migration), safe code remediation (qryx fix/--open-pr), Terraform detector + rule - Phase 4 -- policy engine (
--policy, exit 3), drift-gated (--policy-new-only), evidence export (--format evidence), governance dashboard (--format dashboard), evidence trail + trend (--save-evidence/qryx trend) - Phase 4 -- evidence signing + verification (
--sign-key/qryx verify-evidence, ed25519, ECDSA P-256, or ML-DSA (FIPS 204, all three security levels)) - Phase 4 -- trend monitoring: HTML chart (
trend --html) + regression CI gate (trend --fail-on-regression) - Terraform -- HCL-accurate detection via hashicorp/hcl (heredoc/interpolation-safe) +
google_kms_crypto_key - Phase 4 -- NCSC PQC readiness report (
--format ncsc/ncsc-html): 2028/2031/2035 milestones, deterministic on-track/at-risk/not-started verdicts, self-documenting 2031 highest-priority criteria; Ed25519 now maps to ML-DSA (FIPS 204) in the agility/migration plan - Phase 4 --
qryx agents: AI-agent infrastructure connector inventorying the agent-governance stack's own trust surface (Agent Passport attestation crypto, agent-event NDJSON hash-chain integrity) into the same asset graph - ML-DSA signing (
internal/attest): stdlibcrypto/mldsa(Go 1.27,toolchain go1.27.2ingo.mod), additive 3rd case in the existing ed25519/ECDSA switch; live-verified against real openssl-generated keys end to end, all three security levels (ML-DSA-44/65/87) - Agent-event export (
--events,internal/exporter): the emitter half of qryx's agent-passport SPEC.md §9 adoption row (qryx agentsalready shippedagent_id-as-evidence-subject as a consumer);crypto_finding,crypto_drift,policy_violation,evidence_signedper SPEC.md §6.2, agent_id never fabricated; live-verified end to end for all four types - Test/production separation: test-code findings (language path conventions
plus Rust's in-file
#[cfg(test)]blocks) are counted and reported but excluded from the production inventory by default;--include-testsrestores the old behaviour
Roadmap and rationale: qryx-plan.md.




