The Scanii CLI (sc) helps you build, test, and manage your Scanii integration right from the terminal.
With the CLI, you can:
- Interact with the Scanii API: scan files, manage auth tokens, and check account info
- Start a local server that simulates the Scanii API for integration testing
- Process single files or entire directories with concurrent workers and byte-level upload progress
brew trust --formula scanii/tap/scanii-cli
brew install scanii/tap/scanii-cliFrom Homebrew 6.0, formulae in third-party taps must be trusted before Homebrew
will load them, so without the first command the install fails with Refusing to load formula scanii/tap/scanii-cli from untrusted tap scanii/tap. Trust is
recorded locally in ~/.homebrew/trust.json — it is your decision to make, and
nobody can grant it on your behalf. Use brew trust scanii/tap instead to trust
everything in the tap rather than just this formula.
brew trust prompts for nothing, so it drops straight into a CI script. It has
existed since Homebrew 5.1.15; on anything older, skip it and just install.
Trust persists, so upgrades need nothing extra:
brew upgrade scanii-clicurl -fsSL https://raw.githubusercontent.com/scanii/scanii-cli/main/install.sh | shThe installer downloads the matching release archive from GitHub, verifies it against checksums.txt, and installs sc to ~/.local/bin (override with SCANII_CLI_BIN_DIR). Pin a specific version with SCANII_CLI_VERSION=1.6.0.
A container image is published to the GitHub Container Registry:
docker run ghcr.io/scanii/scanii-cli:latestPrevious versions are listed here.
Pre-built binaries for macOS, Windows, and Linux are available on the releases page.
On macOS, you may need to remove the quarantine attribute before running:
xattr -d com.apple.quarantine /path/to/scSet up your API credentials and endpoint:
sc profile create --endpoint api-us1.scanii.com --credentials YOUR_KEY:YOUR_SECRETThis creates a default profile stored in ~/.config/scanii-cli/. You can create named profiles for different environments:
sc profile create staging --endpoint localhost:4000 --credentials key:secretList configured profiles:
sc profile listsc pingUse a non-default profile with the -p flag:
sc -p staging pingSynchronous scan (blocks until the result is ready):
sc files process /path/to/file.pdfDelete a processed result once you no longer need it:
sc files delete RESULT_IDProgress is measured in bytes as they go out on the wire, so a large file advances smoothly while it uploads:
Using endpoint: api-us1.scanii.com and API key: mykey
Processing file /path/to/backup.tar
Uploading backup.tar [█████████████████░░░░░░░░░░░░░] 58% (183.2 MB/314.6 MB)
Once the last byte is sent, the wait is on the server, so the bar gives way to a spinner until the result lands:
⠹ Analyzing backup.tar
# /path/to/backup.tar:
id: ff03467da11f417aa99845c91793ce0c
checksum/sha1: 0c4f4f069728d13bf481738ac926381477fb8975
content type: application/octet-stream
content length: 314.6 MB
creation date: Sat, 08 Aug 2026 08:41:43 EDT
findings: none
metadata: none
✔ Completed in 4.2 s, 1 file(s) analyzed. Throughput 74.9 MB/s
✔ Files with findings: 0, unable to process: 0 and successfully processed: 1
Every request records the X-Scanii-Request-Id the API returned — the id
support needs to look a specific scan up on the server side. A request that
failed quotes it in the error; otherwise --perf prints it, and -v logs it
for every request:
2026-08-08 08:41:43.508 DEBUG 24068 internal/commands/file/common.go:38 : processed file path=/path/to/backup.tar status=201 request_id=req_9f3a1c
Asynchronous scan (returns immediately with a pending result ID):
sc files async /path/to/file.pdfRetrieve the result of an async scan:
sc files retrieve RESULT_IDRetrieve the processing trace for a result:
sc files trace RESULT_IDSubmit a URL for server-side fetch and scan:
sc files fetch https://example.com/document.pdfWait for the result instead of returning immediately:
sc files fetch --wait 30 https://example.com/document.pdfProcess all files in a directory with concurrent workers:
sc files process /path/to/directorySkip hidden files and attach metadata:
sc files process --ignore-hidden --metadata env=production,scan_type=nightly /path/to/directoryEmpty files are skipped rather than uploaded — the API rejects empty content, so
sending it only buys a 400 — and the CLI reports how many it passed over:
Processing recursive directory /path/to/directory with ~12 files | ~50.3 MB
Skipping 3 empty file(s)
Directories are tracked the same way. The bar fills with the bytes uploaded so far across every file, and the label counts the files that have come back:
Using endpoint: api-us1.scanii.com and API key: mykey
Processing recursive directory /path/to/directory with ~12 files | ~50.3 MB
Files 7/12 [████████████████████████░░░░░░░░░░░░░░░░░░] 58% (29.7 MB/50.3 MB)
On completion, any file that came back with findings is listed, so a directory scan tells you where the malware was and not just how much of it there was:
Using endpoint: api-us1.scanii.com and API key: mykey
Processing recursive directory /path/to/directory with ~12 files | ~50.3 MB
Files 12/12 [████████████████████████████████████████] 100% (50.3 MB/50.3 MB)
## Files with findings
# /path/to/directory/quarantine/sample.txt:
id: e353d97476fe40c6abd20418efe82b96
checksum/sha1: 7da9d3b0c68b1d0543acb65af4220a4745607557
content type: text/plain; charset=utf-8
content length: 36 B
creation date: Sat, 08 Aug 2026 09:07:46 EDT
findings: content.malicious.eicar-test-signature
metadata: none
✔ Completed in 3.1 s, 12 file(s) analyzed. Throughput 16.2 MB/s
✔ Files with findings: 1, unable to process: 0 and successfully processed: 12
Files the API rejected are reported as they happen, with the reason the API gave and the request id to quote in a support ticket. When more than a handful fail, the ones that scrolled past are listed again at the end of the run:
error: /path/to/directory/huge.iso — status 413: File is too large (request id req_9f3a1c)
✔ Completed in 3.1 s, 11 file(s) analyzed. Throughput 16.2 MB/s
warning: Files with findings: 1, unable to process: 1 and successfully processed: 11
error: 1 of 12 file(s) could not be processed
sc files process and sc files async exit non-zero if any file could not be
processed, so a scan that half-failed does not pass for a clean one in CI.
--perf prints a breakdown of the API requests a command made, which is how to
tell a slow network apart from a slow scan. It works on every sc files
command:
sc files process --perf /path/to/backup.tar
sc files retrieve --perf RESULT_ID
sc files fetch --perf --wait 30 https://example.com/document.pdf## Performance
request id: req_9f3a1c
dns: 24 ms
tcp connect: 31 ms
tls handshake: 58 ms
request transfer: 3.4 s
server processing: 612 ms
response transfer: 1.2 ms
total: 4.1 s
client overhead: 104 ms
connection: new
The phases run in that order and, give or take the wait for a free connection,
add up to total:
| Phase | What it covers |
|---|---|
dns |
Resolving the endpoint's host name |
tcp connect |
Opening the socket |
tls handshake |
Negotiating TLS |
request transfer |
Sending the request — for a scan, the upload |
server processing |
Last request byte out to first response byte back — the API's work plus the round trip carrying the question and the answer |
response transfer |
First response byte to the last byte of the body |
total |
The whole exchange |
client overhead |
The rest of the run — reading and hashing the file, building the request, printing the result |
server processing is measured from this end, so it cannot separate the API's
work from the round trip it takes to ask and be answered; against a distant
endpoint it reads higher than what the server reports for the same request, by
about one round trip. It is the floor for a scan — client overhead and the
three connection phases are the parts a caller can do something about.
A phase that did not happen reads n/a: a pooled connection resolves no name
and shakes no hands, and a plaintext endpoint never reaches the TLS phase. So
does a phase that finished inside the clock's resolution, which on Windows is a
millisecond — not a distinction that matters for the latencies this is for.
A command that makes more than one request reports the mean instead, and counts
how many of those requests rode on a connection that was already open. A
directory scan sends one request per file; retrieve --wait and fetch --wait
each poll until the result lands:
## Performance (mean of 128 requests)
dns: 1 ms
tcp connect: 2 ms
tls handshake: 12 ms
request transfer: 184 ms
server processing: 397 ms
response transfer: 1.1 ms
total: 597 ms
connections: 96 of 128 reused
connections is worth watching on a directory scan. The pool is sized to
--concurrency, so once the first wave of files has opened its connections the
rest of the run should reuse them; a low reuse count means the run is paying a
connect and a TLS handshake per file, and none of that shows up in the API's own
timings.
A scan keeps 32 requests in flight by default. That number belongs to the link
rather than to the machine: once the uplink is full, more requests in flight buy
no throughput and cost a connection — and a TLS handshake — each. If --perf
shows request transfer is not what the run is waiting on, there is headroom to
raise --concurrency; if a directory scan reports few reused connections, it is
already higher than the link can use.
Create a short-lived auth token (default timeout: 300 seconds):
sc auth-token create --timeout 600Retrieve or revoke a token:
sc auth-token retrieve TOKEN_ID
sc auth-token delete TOKEN_IDThe local server is the primary way to integration-test code that talks to the Scanii API. It implements the full v2.2 API surface including file processing, auth tokens, callbacks, and fetch-by-URL -- all without requiring real credentials or network access to Scanii servers.
sc serverOutput:
Scanii local server starting
API Key: key
API Secret: secret
Engine Rules: 5
Callback Wait: 100ms
Address: http://localhost:4000
Sample usage: curl -u key:secret http://localhost:4000/v2.2/ping
We also provide fake sample files you can use to trigger findings:
content.image.nsfw.nudity: http://localhost:4000/static/samples/image.jpg
content.en.language.nsfw.0: http://localhost:4000/static/samples/language.txt
content.malicious.local-test-file: http://localhost:4000/static/samples/malware
Default credentials are key / secret. Override them with flags:
sc server --key my-key --secret my-secret --address 0.0.0.0:8080| Flag | Default | Description |
|---|---|---|
-a, --address |
localhost:4000 |
Listen address |
-k, --key |
key |
API key |
-s, --secret |
secret |
API secret |
-e, --engine |
built-in | Path to a custom engine rules JSON file |
-d, --data |
temp dir | Directory for storing processing results |
-w, --callback-wait |
100ms |
Delay before firing callbacks; overrides callback_wait in the engine config |
All endpoints are under the /v2.2/ prefix and require HTTP Basic Auth:
| Method | Path | Description |
|---|---|---|
GET |
/v2.2/ping |
Health check |
GET |
/v2.2/account.json |
Account info (returns mock data) |
POST |
/v2.2/files |
Synchronous file scan |
POST |
/v2.2/files/async |
Async file scan (returns pending ID) |
POST |
/v2.2/files/fetch |
Fetch remote URL and scan |
GET |
/v2.2/files/{id} |
Retrieve scan result |
DELETE |
/v2.2/files/{id} |
Delete scan result |
GET |
/v2.2/files/{id}/trace |
Retrieve processing trace |
DELETE |
/v2.2/files/{id}/trace |
Delete processing trace |
POST |
/v2.2/auth/tokens |
Create auth token |
GET |
/v2.2/auth/tokens/{id} |
Retrieve auth token |
DELETE |
/v2.2/auth/tokens/{id} |
Delete auth token |
Static sample files are served without authentication under /static/.
Ping:
curl -u key:secret http://localhost:4000/v2.2/ping{"key":"key","message":"pong"}Synchronous file scan:
curl -u key:secret -F "file=@test.pdf" http://localhost:4000/v2.2/files{
"id": "fd33128a8da445d3b8308fe6d1588829",
"checksum": "da39a3ee5e6b4b0d3255bfef95601890afd80709",
"content_length": 1024,
"content_type": "application/pdf",
"findings": [],
"metadata": {},
"creation_date": "2024-02-08T13:38:02.074502Z"
}Scan with metadata and callback:
curl -u key:secret \
-F "file=@test.pdf" \
-F "metadata[env]=staging" \
-F "metadata[ticket]=JIRA-123" \
-F "callback=https://your-app.example.com/webhook" \
http://localhost:4000/v2.2/files/asyncFetch and scan a remote URL:
curl -u key:secret \
-d "location=http://localhost:4000/static/eicar.txt" \
http://localhost:4000/v2.2/files/fetchScan the EICAR test file (triggers a malware finding):
curl -u key:secret \
-F "file=@-" \
http://localhost:4000/v2.2/files < <(curl -s http://localhost:4000/static/eicar.txt)Create and retrieve an auth token:
# Create a token valid for 600 seconds
curl -u key:secret -d "timeout=600" http://localhost:4000/v2.2/auth/tokens
# Retrieve it
curl -u key:secret http://localhost:4000/v2.2/auth/tokens/TOKEN_ID
# Delete it
curl -u key:secret -X DELETE http://localhost:4000/v2.2/auth/tokens/TOKEN_IDThe local server does not perform real content analysis. Instead, it computes SHA-1 and SHA-256 hashes of uploaded content and matches them against a static rule database. The built-in rules include signatures for:
| Sample file | Finding | Trigger |
|---|---|---|
/static/eicar.txt |
content.malicious.eicar-test-signature |
Standard EICAR test string |
/static/samples/image.jpg |
content.image.nsfw.nudity |
Sample NSFW image |
/static/samples/language.txt |
content.en.language.nsfw.0 |
Sample unsafe-language text |
/static/samples/malware |
content.malicious.local-test-file |
Generic malware test file |
Any file that does not match a known signature returns an empty findings list.
For more sophisticated testing, provide your own rules file:
sc server --engine /path/to/rules.jsonThe JSON format is:
{
"callback_wait": "100ms",
"rules": [
{
"format": "sha256",
"content": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"result": "your.custom.finding"
}
]
}callback_wait is optional and must be a duration string such as "100ms" or
"2s"; --callback-wait overrides it when passed.
Supported hash formats are sha1 and sha256. Generate a hash for your test file with:
shasum -a 256 /path/to/your/test-fileThe rules in your file replace the built-in ones rather than adding to them, so
Engine Rules: in the startup banner reflects your file alone. A config that is
missing, unreadable, or not a valid rules file stops the server from starting
instead of quietly falling back to the built-in rules:
% sc server --engine foo
error: opening engine config: open foo: no such file or directory
The local server supports callbacks. When a callback URL is included in an async or fetch request, the server POSTs a JSON payload to that URL containing the processing result (id, findings, checksum, content_type, content_length, creation_date, and metadata). The callback fires after a configurable delay: 100ms by default, callback_wait in an --engine config, or --callback-wait which overrides both. The effective value is shown as Callback Wait: in the startup banner.
Callbacks are fire-and-forget: if the target URL is unreachable, the delivery fails silently and the server continues operating normally.
The Docker image is the simplest way to run the local server as a service in CI pipelines for integration testing.
Use the services block to start the local server alongside your test job:
jobs:
test:
runs-on: ubuntu-latest
services:
scanii:
image: ghcr.io/scanii/scanii-cli:latest
ports:
- 4000:4000
options: >-
--health-cmd "wget -qO- http://localhost:4000/v2.2/ping || exit 1"
--health-interval 5s
--health-timeout 3s
--health-retries 5
env:
SCANII_ENDPOINT: http://localhost:4000
SCANII_KEY: key
SCANII_SECRET: secret
steps:
- uses: actions/checkout@v4
- name: Run integration tests
run: make test-integrationIf you need the local server across a matrix of operating systems (including macOS and Windows where Docker services are not available), download the binary from GitHub Releases instead:
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- name: Download scanii-cli
shell: bash
run: |
case "${{ runner.os }}" in
Linux) OS=linux; ARCH=amd64; EXT=tar.gz ;;
macOS) OS=darwin; ARCH=amd64; EXT=tar.gz ;;
Windows) OS=windows; ARCH=amd64; EXT=zip ;;
esac
gh release download --repo scanii/scanii-cli \
--pattern "scanii-cli-*-${OS}-${ARCH}.${EXT}" \
--dir /tmp
# Extract and add to PATH
if [ "$EXT" = "tar.gz" ]; then
tar -xzf /tmp/scanii-cli-*.${EXT} -C /tmp
else
unzip /tmp/scanii-cli-*.${EXT} -d /tmp
fi
- name: Start local server
shell: bash
run: |
/tmp/scanii-cli-*/sc server &
# Wait for the server to be ready
for i in $(seq 1 30); do
curl -sf http://localhost:4000/v2.2/ping && break
sleep 1
done
- name: Run integration tests
run: make test-integrationtest:
image: your-app-image:latest
services:
- name: ghcr.io/scanii/scanii-cli:latest
alias: scanii
command: ["server", "--address", "0.0.0.0:4000"]
variables:
SCANII_ENDPOINT: http://scanii:4000
SCANII_KEY: key
SCANII_SECRET: secret
script:
- make test-integrationFor local development, add the server to your docker-compose.yml:
services:
scanii:
image: ghcr.io/scanii/scanii-cli:latest
command: ["server", "--address", "0.0.0.0:4000"]
ports:
- "4000:4000"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:4000/v2.2/ping"]
interval: 5s
timeout: 3s
retries: 5
your-app:
build: .
depends_on:
scanii:
condition: service_healthy
environment:
SCANII_ENDPOINT: http://scanii:4000
SCANII_KEY: key
SCANII_SECRET: secretWhen using the local server (Docker or binary), the default credentials are:
| Setting | Value |
|---|---|
| Endpoint | http://localhost:4000 |
| API Key | key |
| API Secret | secret |
| Flag | Description |
|---|---|
-v, --verbose |
Enable debug logging |
-p, --profile NAME |
Use a named profile (default: default) |
| Command | Description |
|---|---|
sc profile create [name] |
Create or update a profile |
sc profile list [name] |
List profiles or show details of one |
sc profile delete <name> |
Delete a profile |
sc ping |
Test API connectivity |
sc account |
Show account information |
sc files process <path> |
Synchronous file/directory scan |
sc files async <path> |
Asynchronous file/directory scan |
sc files fetch <url> |
Fetch and scan a remote URL |
sc files retrieve <id> |
Retrieve a scan result |
sc files delete <id> |
Delete a scan result |
sc files delete-trace <id> |
Delete a scan result's processing trace |
sc files trace <id> |
Retrieve the processing trace for a scan result |
sc auth-token create |
Create a temporary auth token |
sc auth-token retrieve <id> |
Retrieve token details |
sc auth-token delete <id> |
Revoke a token |
sc server |
Start the local server |
sc version |
Display version and build info |
Run sc help or sc <command> --help for detailed usage of any command.
- The local server engine does not perform real content analysis; it matches files by hash only
- Requests that fail to download a remote URL via
/files/fetchrecord an error but the result is still stored
See LICENSE for details.