Security
Secret scanning for your code: submit sources, get findings, masked never raw.
Also for this capability: API · CLI · MCP · SDKs
Secret scanning for your code: submit sources, get findings, masked never raw.
| Base URL | https://api.hanzo.ai |
| Operations | 7 |
| Auth | Authorization: Bearer $HANZO_API_KEY |
Specification
HIP-1047 · Security Scanning — Draft · read the specification →
/v1/security scans submitted source for hardcoded secrets and keeps the
findings. It is served by apps/security in hanzoai/cloud.
One rule shapes everything else: the submitted content is NEVER stored, and a finding never carries the secret it found. What persists is a masked preview and the digest of the raw secret — enough to recognise and to triage, not enough to use.
Motivation
A scanner that stores what it scanned is a repository of every secret its users ever leaked, held by a service whose whole purpose is telling them not to do that. The interesting design question is therefore not detection; it is what a finding is allowed to remember.
Specification
The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be interpreted as in RFC 2119.
Content is read, never kept
Submitted files are scanned IN MEMORY. A finding persists its rule, its path and
line, a MASKED preview — first and last characters kept, the middle starred — and
the SHA-256 of the raw secret (apps/security/security.go:336,
apps/security/detect/detect.go:190-199).
The digest is what makes the same secret recognisable across scans and after rotation WITHOUT the secret ever being written down. It is a correlation key, not a recovery path.
Everything stored is org-scoped, and every miss is a 404
A scan and every finding on it are filed under the caller's validated org, and a
caller with no org is refused. An identifier belonging to another org is the SAME
404 as an identifier that never existed
(apps/security/security.go:451), so the surface cannot be used to learn what
exists elsewhere.
Bounded submissions
One submission is bounded in file count and in total content
(apps/security/security.go:44-45). A caller with more source splits it across
scans. The bound is on the REQUEST, so a single call can neither exhaust the
process nor wedge the engine.
Prepaid, at a resolved fee
A scan is one metered unit, billed under the meter kind security.scan
(apps/security/security.go:26). The caller's balance MUST cover it BEFORE the
engine runs (apps/security/security.go:375): a check downstream of the work
is a bill for compute already spent. The debit lands through the fleet's one
resource meter on the caller's own commerce ledger
(apps/security/security.go:413).
The fee resolves through the platform's own policy default —
CLOUD_SECURITY_FEE_CENTS, defaulting to the fleet-wide $1.00 provision fee,
zero making scanning free and un-gated (apps/security/security.go:28-31) —
never a number invented by this subsystem. This is stated because the failure is quiet: the
surface declared itself metered from the start and passed a literal zero as the
amount, and a zero debit posts no ledger entry — so the platform required standing
to scan and then charged for none of them.
The METERED amount MUST be the same value the balance was checked against, read once, so a charge can never exceed what was authorized.
Off the request path there is no payer, and that is a refusal rather than an unbilled scan.
The audit record carries the tally, not the findings
A scan is recorded as an audited action with who ran it and what it found by count. The redacted findings are the evidence and live in the store; the tally is the outcome the audit log carries (AU-3).
Unauthenticated reads are the ones that disclose nothing
Liveness and the detection catalog take no org: the first measures this process and answers while any tenant state is cold, and the second is the same for everyone. Everything that touches stored results requires a validated org.
Every operation is typed
Each operation is a typed input and answer, so the schema, the prose, the tool,
the CLI command and every generated SDK method are projections of the handler
itself. apps/security/typed_wire_test.go holds the exceptions as a CLOSED list
that is currently EMPTY, and fails on an operation that is neither typed nor named
there — so the next route added is typed by default and dropping one out takes a
deliberate edit with a reason.
A filter value outside its vocabulary is REFUSED rather than ignored, so a typo in a severity filter cannot read as "no findings".
Store, addresses, events, stage, upstreams
The one store this capability owns is its findings database: an org-scoped
SQLite file holding scans and their redacted findings and nothing else,
encrypted at rest where the build carries the cipher
(apps/security/store.go:13-27). The addresses are /v1/security/scans
(POST and GET, and GET of one by id), /v1/security/findings (GET, and GET of
one by id), /v1/security/rules and /v1/security/health
(plugin/security/openapi.json) — nothing else answers under the prefix.
It publishes no event, so a customer's webhooks receive nothing from it; the
record of a scan is §5's audit entry. Beyond the request span every route
already gets it emits nothing to observability. Its stage is beta
(manifest/apps.go:338, Stage: Beta), so it is reached by flag until
promoted. The
detection engine derives from no outside project — it is a dependency-free
leaf over the standard library, no scanner binary embedded or shelled out to
(apps/security/detect/detect.go:1-13).
Rationale
The alternative to masking is storing the match and encrypting it, which turns every finding into a key-management problem and makes the blast radius of this store the union of its users' secrets. A digest plus a mask supports the two things a reviewer actually does — recognise which secret this is, and confirm it stopped appearing — with nothing to steal.
Gating before the engine rather than after costs a balance read on every submission and removes the case where an unfunded caller consumes the compute and then learns they could not pay for it.
Security Considerations
The threat this capability creates is itself: a service that receives source code containing live credentials. The mitigations are that the content is not persisted, the finding cannot reconstruct the secret, and the store is partitioned by org with denials that disclose nothing.
The digest deserves care. SHA-256 of a raw secret is reversible by guessing when the secret is low-entropy — a short password, a well-known test key — so the digest MUST be treated as sensitive within the tenant's own scope, and MUST NOT be published across tenants as a shared correlation key.
Submission bounds are a denial-of-service control, not tidiness: the engine runs regular expressions over caller-supplied text, so the size of that text is the size of the work a single request can buy.
Four surfaces
| Surface | Reaches this capability as | Coverage |
|---|---|---|
| REST | security at its own prefix | 7 operations |
| CLI | hanzo security … | 7 of 7 |
| SDK | SecurityApi in every published client | 7 methods |
| MCP | tool security on https://api.hanzo.ai/v1/mcp | 7 operations, 1 under the document's own id — ask describe for the rest |
Quickstart
export HANZO_API_KEY=sk-... # console.hanzo.ai → API keysThen the first call — a read that needs nothing but the key. GET /v1/security/rules, operation get_security_rules:
hanzo security rulesimport { Configuration, SecurityApi } from 'hanzoai';
const api = new SecurityApi(new Configuration({ accessToken: process.env.HANZO_API_KEY }));
const { data } = await api.getSecurityRules();from hanzoai.cloud import ApiClient, Configuration
from hanzoai.cloud.api import SecurityApi
client = ApiClient(Configuration(access_token=os.environ["HANZO_API_KEY"]))
result = SecurityApi(client).get_security_rules()cfg := cloud.NewConfiguration()
cfg.AddDefaultHeader("Authorization", "Bearer "+os.Getenv("HANZO_API_KEY"))
client := cloud.NewAPIClient(cfg)
resp, _, err := client.SecurityAPI.GetSecurityRules(context.Background()).Execute()
if err != nil {
return err
}use hanzo_cloud::apis::{configuration::Configuration, security_api};
let mut cfg = Configuration::new();
cfg.bearer_access_token = std::env::var("HANZO_API_KEY").ok();
let result = security_api::get_security_rules(&cfg, Default::default()).await?;import ai.hanzo.cloud.ApiClient;
import ai.hanzo.cloud.api.SecurityApi;
ApiClient client = new ApiClient();
client.setRequestInterceptor(b -> b.header("Authorization", "Bearer " + System.getenv("HANZO_API_KEY")));
var result = new SecurityApi(client).getSecurityRules();curl https://api.hanzo.ai/v1/security/rules \
-H "Authorization: Bearer $HANZO_API_KEY"MCP reaches security through the security tool, which names its 7 operations with its own verbs — this one among them, under a name only MCP declares. describe explains any of them:
curl -X POST https://api.hanzo.ai/v1/mcp \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "describe",
"arguments": {
"op": "list_security_findings"
}
}
}'Answers 200 with object — ok.
Endpoints
| Endpoint | What it does |
|---|---|
GET /v1/security/findings/{id} | Returns a single finding: which rule fired, where (path and line), the masked preview and the SHA-256 fingerprint of the secret — the raw secret is… |
GET /v1/security/findings | Is the org's findings — rule, severity, path, line, masked preview and fingerprint — newest first, across scans or within one. |
GET /v1/security/health | Reports that the scanning subsystem is serving and how many secret-detection rules the engine holds. |
GET /v1/security/rules | Is the secret-detection catalog the engine scans with. |
GET /v1/security/scans/{id} | Returns one scan together with every finding on it, so the detail view is one round-trip rather than a list call per scan. |
GET /v1/security/scans | Is the org's scan history, newest first, each as the same summary the submission answered — files read, findings fired, tally by severity. |
POST /v1/security/scans | Runs the detection engine over a batch of files and answers 201 with the scan summary: how many files were read, how many findings fired, and the… |
How is this guide?