| name | arnica-api |
| description | Reference for the Arnica security API: endpoints, auth, query params, error model, pagination, and the writable status enum. Use when calling Arnica endpoints directly, querying findings, managing products/assets/owners, scanning SBOMs, reviewing inventory, or integrating with the Arnica platform. |
Arnica API Skill
Reference for the Arnica API: endpoints, auth, query params, and the error
model. Use this skill any time you need to call the Arnica API directly.
Used by arnica-fix — the triage workflow defers to this skill for
endpoint reference, auth, pagination, and the writable status enum. If you're
running an end-to-end finding triage, load arnica-fix and this skill
together.
Base Information
Authentication Setup
Required Token
Get your token from the Arnica dashboard, then expose it to your tooling. The
simplest option is an environment variable — always quote the value:
export ARNICA_API_TOKEN="your-token-here"
To call the API directly, pass it in the Authorization header as
Authorization: Bearer <token>.
Copy-paste note (zsh): paste only the lines inside a code block, and keep
tokens/values quoted. Unquoted parentheses () — common in the prose and
comments around these snippets — make zsh emit
zsh: unknown sort specifier because it treats (...) as a glob qualifier.
Quoting (or prefixing a command with noglob) avoids this.
Local token file
Store the token locally in a secure file and read it explicitly when needed.
The printf form below is paste-safe (no heredoc, no inline comments):
mkdir -p ~/.arnica
umask 077
printf 'TOKEN=%s\n' 'PASTE_YOUR_REAL_TOKEN_HERE' > ~/.arnica/.prod.env
chmod 600 ~/.arnica/.prod.env
umask 077 and chmod 600 ensure the file is owner read/write only (0600).
Loading the token in scripts and ad-hoc commands — use a targeted grep
instead of source-ing the file. This avoids leaking unrelated env vars and
works identically in arnica-fix:
TOKEN=$(sed -n 's/^TOKEN=//p' ~/.arnica/.prod.env)
curl -sS -H "Authorization: Bearer $TOKEN" https://api.app.arnica.io/v1/auth/token
Do not use cut -d= -f2 to extract the token. Arnica tokens are
base64-encoded and frequently end in = or == padding; cut -d= -f2 splits
on = and keeps only the second field, silently dropping the padding. That
yields a truncated token that Arnica rejects with a generic 403 Forbidden —
the same response as an invalid/expired key, so the failure looks like an auth
problem rather than a parsing bug. sed -n 's/^TOKEN=//p' strips only the
leading TOKEN= prefix and preserves the rest of the line verbatim.
This keeps auth credentials separate from code and makes it easy to reuse the
token in scripts.
Never commit this file or the token.
- Keep
~/.arnica/.prod.env outside any git repository. The ~/.arnica/ location above is intentional.
- If you must store auth helpers inside a repo, add
.arnica/, *.env, and .prod.env to .gitignore before writing the token.
- Treat the token like a password: don't paste it into chat, issues, screenshots, or logs. Rotate it immediately if it leaks — Arnica dashboard, then API tokens, then revoke.
- Prefer per-environment files such as
.prod.env and .staging.env, each with the least-privileged scopes required. See "Required Scopes" below.
Token Validation
Validate the token with GET /v1/auth/token:
curl -sS -H "Authorization: Bearer $TOKEN" https://api.app.arnica.io/v1/auth/token
200 returns {"arnicaOrgId": "...", "keyId": "..."} — the token is valid.
403 means the token is invalid, expired, or revoked. Arnica returns 403, not 401, for auth failures.
Core API Operations
1. Risk Management
Get Risk Findings
# List all findings
GET /v1/risks/findings
# Get specific finding
GET /v1/risks/findings/{id}
# Query: type (SAST, SCA, IAC, LICENSE, REPUTATION, SECRET) — uppercase
# Query: severity (critical, high, medium, low, info, unknown) — lowercase, exact match
# Query: status — see full enum below; common: requires_review, in_progress,
# risk_accepted, resolved, resolved_automatically,
# dismiss_not_accurate, dismiss_no_capacity, dismiss_risk_is_tolerable
# See "Finding Triage & Review Workflow" below for full filter semantics.
Permission Risks (GitHub/Azure)
# GitHub permission risks
GET /v1/risks/permissions/github/{githubPermissionRiskType}
# githubPermissionRiskType: owner | admin | maintain | write | codeowners
# Azure permission risks
GET /v1/risks/permissions/azure/{azurePermissionRiskType}
# azurePermissionRiskType: admin | codecontributor | pullrequestcontributor
Returns 404 {"message":"Report not found"} when the report has not been generated for the org yet (the path/type is still valid).
Hardening Recommendations
# GitHub stale users
GET /v1/risks/hardening/github/stale/users
# Azure stale users
GET /v1/risks/hardening/azure/stale/users
# Stale repositories
GET /v1/risks/hardening/github/stale/repositories
GET /v1/risks/hardening/azure/stale/repositories
Update Finding Status
PUT /v1/risks/{id}/status
Content-Type: application/json
{
"status": "risk_accepted",
"reason": "Updated via API"
}
# Body fields (FindingUpdateDto):
# status (required) — one of the writable statuses below
# reason (required) — free-text rationale (min length 1)
#
# Writable statuses (from FindingUpdateDto.enum — narrower than the read enum):
# resolved, in_progress, requires_review, pending_review, risk_accepted,
# dismiss_no_capacity, dismiss_not_accurate, dismiss_risk_is_tolerable,
# dismiss_secret_not_used
#
# DO NOT send these (read-only / system-managed):
# resolved_automatically, dismissed_automatically, awaiting_user_input,
# mitigation_in_progress, mitigation_pending_pr, mitigation_failed,
# dismiss_no_option_chosen_yet
#
# Path note: {id} is the OpenAPI placeholder name `{sortKey}`, but the value
# you put there is the finding `id` from the listing response (the top-level
# `sortKey` field is always `null` — ignore it). The id contains `/`, `:`,
# `@`, etc. and MUST be URL-encoded.
#
# Rollback: to undo a wrong dismissal, PUT `requires_review` (it's in the
# write enum) — this returns the finding to the triage queue.
# Example:
# ID='01bc4...0510/js/package-lock.json:remove-strict-webpack-plugin@0.1.2'
# ENC=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=''))" "$ID")
# curl -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
# -d '{"status":"resolved","reason":"verified false positive"}' \
# "https://api.app.arnica.io/v1/risks/$ENC/status"
# Successful update returns HTTP 204 (no body).
2. Products Management
List Products
GET /v1/products
# Query parameters (verified against the live OpenAPI spec):
# - offset: pagination offset (default 0, max 999999)
# - limit: items per page (default 100, max 200)
# - name: filter by product name (substring match)
# - management: filter by ManagementType (automatic | manual)
Returns {"items": [...]} (a wrapped object). Pagination uses offset/limit, not skip/take/sort. (Unknown query params are silently ignored, so misspelled params return the default first 100 items.)
Heads-up on pagination naming: /v1/products uses offset/limit, while /v1/risks/findings uses page/limit. Both happen to use limit, but the offsetting parameter differs.
Get Product Details
GET /v1/products/{productId}
Create Product
POST /v1/products
Content-Type: application/json
{
"name": "My Security Product"
}
The ProductCreate body accepts only name (1–50 chars). Sending description, businessImportance, etc. returns 400 "property X should not exist". Newly created products default to management: "manual" with empty assets/owners/champions. Returns 409 if the name already exists.
Update Product
PATCH /v1/products/{productId}
Content-Type: application/json
{
"name": "Updated Name"
}
The ProductUpdate body accepts only name. For everything else, use the dedicated sub-resource endpoints:
- Assets →
POST /v1/products/{productId}/assets
- Owners →
POST /v1/products/{productId}/owners (see "Owners & Champions" below)
- Security champions →
POST /v1/products/{productId}/champions
Delete Product
DELETE /v1/products/{productId}
3. Assets Management
Add Asset (Repository or Location) to a Product
POST /v1/products/{productId}/assets
Content-Type: application/json
{
"url": "https://github.com/my-org/my-repo"
}
The AssetAdd body accepts only url (1–2000 chars). Sending type, scmType, or integrationId returns 400 "property X should not exist". Arnica derives the SCM/integration from the URL itself.
Supported URL examples (from the live OpenAPI examples):
- GitHub repo:
https://github.com/my-org/my-repo
- GitHub org/location:
https://github.com/my-org
- Azure DevOps repo:
https://dev.azure.com/MyOrg/MyProj/_git/MyRepo
- Azure DevOps org:
https://dev.azure.com/MyOrg
- BitBucket Server:
http://my.bitbucket.deployment.net/projects/PROJ/repos/my-repo/browse
- BitBucket Cloud:
https://bitbucket.org/my-workspace/my-repo
- GitLab repo:
https://gitlab.com/my-group/my-repo
- GitLab group:
https://gitlab.com/groups/my-group
Responses: 201 Created, 400 Invalid request, 403 Forbidden (needs products:write), 404 Repo/Location/Product not found, 409 Product already includes this asset.
Update Repository Asset
PATCH /v1/products/{productId}/assets/repositories/{repositoryId}
Content-Type: application/json
{
"slaBranches": ["main", "release/*"],
"pathPrefixes": ["src/"]
}
The RepositoryAssetUpdate body accepts only slaBranches (string[]) and pathPrefixes (string[]). At least one of the two must be specified; each list is bounded 0–100 items. There is no customRiskScore field — that example was incorrect in earlier revisions.
Delete Asset
DELETE /v1/products/{productId}/assets/repositories/{repositoryId}
DELETE /v1/products/{productId}/assets/locations/{locationId}
4. Owners & Champions
Owners and security champions are managed via dedicated endpoints — not through PATCH /v1/products/{productId} (which only accepts name). Both resolve a user by SCM identity and return an Arnica identity id you'll use for subsequent updates/deletes.
Add Product Owner
POST /v1/products/{productId}/owners
Content-Type: application/json
{
"scmType": "github",
"displayName": "Jane Doe"
}
Body shape (ProductOwnerAdd): scmType is required (one of github | azure-devops | bitbucket-server | bitbucket-cloud | gitlab). You must also provide at least one of scmId, username, email, displayName to identify the user. Optional: integrationId (needed to disambiguate when multiple on-prem integrations share usernames) and comment (free-text note).
Responses: 201 Created returning { id, primaryDisplayName, primaryEmail, comment? } — keep id (the identity id) for later PATCH/DELETE. 400 invalid body, 403 missing products:write, 404 product or user not found / search criteria ambiguous, 409 user is already an owner of this product.
Example:
curl -X POST -H "Authorization: Bearer $ARNICA_API_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"scmType":"github","displayName":"Jane Doe"}' \
"https://api.app.arnica.io/v1/products/$PRODUCT_ID/owners"
Update Product Owner
PATCH /v1/products/{productId}/owners/{identityId}
Content-Type: application/json
{
"comment": "Primary on-call for this product"
}
The ProductOwnerUpdate body accepts only comment. To "change" the owner identity, delete and re-add. Returns 200 with the updated ProductOwner.
Delete Product Owner
DELETE /v1/products/{productId}/owners/{identityId}
# 204 No Content on success
Add Security Champion
POST /v1/products/{productId}/champions
Content-Type: application/json
{
"scmType": "github",
"username": "githubuser"
}
Body shape (SecurityChampionAdd): same identity-resolution contract as ProductOwnerAdd — scmType required, plus at least one of scmId | username | email | displayName, optional integrationId. No comment field (champions don't support comments). Returns 201 with { id, primaryDisplayName, primaryEmail, reason } where reason is the AssignmentReason (e.g. codeAuthor). 409 if already a champion.
Delete Security Champion
DELETE /v1/products/{productId}/champions/{identityId}
# 204 No Content on success
Champions have no PATCH endpoint — to modify, delete and re-add.
5. SBOM Scanning (Enterprise)
Start SBOM Scan
POST /v1/sbom/scan/upload
Content-Type: multipart/form-data
File: [sbom.json or sbom.xml]
Check Scan Status
GET /v1/sbom/scan/{scanId}/status
6. Inventory
List Repositories
GET /v1/inventory/repos
# Returns all connected repositories
# Requires scope: inventory:read
7. Security Policies
List Policies
GET /v1/policies
Returns an object keyed by sub-type (currently global, code_risk, secrets). Each value is the full policy doc (rules, conditions, config).
Get Policy by Sub-Type
GET /v1/policies/{subType}
# subType (PolicySubType enum): code_risk | secrets
# Plus the implicit "global" key from /v1/policies.
# (SAST/SCA/IaC/Secrets/License/Reputation are *finding types*, not policy sub-types — passing them returns 404.)
8. Status Checks
Find Status Check
POST /v1/status-checks/find
Content-Type: application/json
{
"integrationType": "github",
"integrationOrgId": "my-org",
"repoId": "my-repo",
"sha": "abc123def456...",
"branch": "feature/foo",
"targetBranch": "main"
}
The StatusCheckFindDto body requires all of: integrationType (one of github | azure-devops | bitbucket-server | bitbucket-cloud | gitlab), integrationOrgId, repoId, sha (commit SHA of the latest PR/MR commit), branch (source), targetBranch (where you're merging to). Optional: projectId (not supported when integrationType: "github"). The earlier {repository, branch} example was incorrect.
integrationOrgId semantics by SCM:
- GitHub → organization name
- Azure DevOps → organization ID
- BitBucket → workspace ID
- GitLab → group ID
Get Status Check
GET /v1/status-checks/{id}
9. Audit Events
Read-only audit log of actions taken in your Arnica org (who did what, when, and
through which interface). Useful for SIEM ingestion, compliance exports, and
answering "who changed this policy / product / integration".
List Audit Events
GET /v1/audit-events
# Query parameters (from the live OpenAPI schema):
# - limit: items per page (default 50, min 1, max 100)
# - offset: number of matching events to skip (default 0, max 999999)
# - startDate: inclusive lower timestamp boundary, ISO 8601 date or datetime
# (e.g. 2026-08-03T00:00:00Z or 2026-08-03)
# - endDate: inclusive upper timestamp boundary, ISO 8601 date or datetime
# (e.g. 2026-08-03T23:59:59.999Z)
# - userId: actor EMAIL address to match (repeat to match any of several)
# - eventType: event DOMAIN to match (repeat to match any of several) — one of
# AUTHENTICATION | AUTHORIZATION | POLICY | PRODUCT | INTEGRATION |
# API | AI | INVENTORY
# - eventAction: action to match (repeat to match any) — one of
# CREATE | READ | UPDATE | DELETE | LOGIN | LOGOUT
# - sortBy: only "timestamp" (the default) is supported
# - sortDesc: newest first when true (default true)
#
# Requires scope: audit:read
Returns a wrapped object { "items": [...], "total": <number> }, where
total is the count of all events matching the filters before pagination —
so unlike /v1/risks/findings, you can paginate against a known total.
Pagination naming: audit-events uses offset/limit (like /v1/products),
not page/limit (which /v1/risks/findings uses).
Repeatable filters: repeat userId, eventType, or eventAction to match
any of the supplied values (OR semantics). Date boundaries are inclusive.
Auth note (differs from the rest of the API): a missing API key returns
401, while an invalid key or one missing the audit:read scope returns
403. Everywhere else Arnica collapses both into 403 — audit-events is the
exception that actually emits 401. Distinguish invalid-key from
missing-scope by the response body's message: missing scope returns
"Missing required scope(s): [audit:read]", invalid key returns a plain
"Forbidden".
Self-referential logging (footgun): every call to GET /v1/audit-events
writes itself into the audit log as
{ eventAction: "READ", eventDomain: "AUTHORIZATION", resourceType: "AuditEventList", resourceName: "Audit Events", newValue: { eventCount: <n> } },
where eventCount is the number of items the caller received (0 on an empty
page). Consequences:
- A polling job will fill the log with its own reads. Poll infrequently, and
attribute reads to a dedicated API key (
apiTokenName) so you can identify
and post-filter them.
- "Show me recent activity" queries will surface the querier's own calls at
the top. Either post-filter client-side (drop items where
resourceType == "AuditEventList") or query for state-changing actions
only using repeated eventAction filters (see the recipe below).
- You cannot suppress these entries server-side via
eventType= alone —
AUTHORIZATION also covers other authorization events you may care about.
Example — all state-changing activity in a date window (excludes every READ,
which drops both the self-referential audit-log reads and other read events),
newest first:
curl -sS -H "Authorization: Bearer $ARNICA_API_TOKEN" \
"https://api.app.arnica.io/v1/audit-events?eventAction=CREATE&eventAction=UPDATE&eventAction=DELETE&eventAction=LOGIN&eventAction=LOGOUT&startDate=2026-08-01&endDate=2026-08-18T23:59:59.999Z&limit=100"
Example — narrower: only policy and integration UPDATEs made via the API:
curl -sS -H "Authorization: Bearer $ARNICA_API_TOKEN" \
"https://api.app.arnica.io/v1/audit-events?eventType=POLICY&eventType=INTEGRATION&eventAction=UPDATE&startDate=2026-08-01&endDate=2026-08-18T23:59:59.999Z&limit=100"
Each item (AuditEventDto) has this shape (all fields present; nullable ones may
be null):
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"timestamp": "2026-08-03T14:30:00.000Z",
"eventAction": "UPDATE",
"eventDomain": "PRODUCT",
"interactionType": "API",
"user": "security.engineer@example.com",
"userDisplayName": "Security Engineer",
"resourceType": "Product",
"resourceId": "b1e0f1c2-9d1a-4a4e-9d8a-8b3f5b7a1234",
"resourceName": "Payments Platform",
"oldValue": { },
"newValue": { },
"apiTokenName": "siem-ingestion"
}
Field notes:
eventAction uses the action enum above; the response field for the domain
is eventDomain (the query parameter that filters it is named eventType
— the names differ between request and response).
interactionType is WEB or API — the interface the action came through.
user is the actor's email; userDisplayName may be null.
oldValue / newValue are full before/after snapshots of the resource,
not a computed diff. To find what actually changed you have to diff them
yourself (e.g. json.dumps(old, sort_keys=True) != json.dumps(new, sort_keys=True)
for a cheap change-check, or difflib.unified_diff on canonicalized JSON
for a field-level view). For AUTHENTICATION events (LOGIN / LOGOUT)
both fields are always null.
- No-op UPDATE events happen — clicking "Save" in the web UI without
changing anything still writes an
UPDATE audit entry with
oldValue == newValue. In a real org sample, ~25% of POLICY UPDATEs
were no-ops. If you're driving a change-tracking dashboard, filter out
oldValue == newValue client-side.
apiTokenName is the name of the API key used (e.g. "skill2"), not the
token value. It is null when the action came through the web UI.
resourceId semantics depend on resourceType:
Policy → the PolicySubType slug (code_risk, secrets, global) —
not a UUID. Same identifiers as GET /v1/policies/{subType}.
Product → the product UUID.
API Key → the key's name (e.g. "skill2").
User → the actor's email.
AuditEventList → the Arnica org id.
resourceName is best-effort human-readable and is often — e.g. for
events it was on nearly every historical event in a live
sample; Arnica only recently started populating it (Aug 2026) with values
like . Never rely on it as a stable filter key — use
+ instead.
Common event shapes
Concrete examples of the most common event types, taken from a live response.
Use these when writing SIEM parsers or dashboards so you know which fields to
expect populated vs. null.
API key created (via WEB) — newValue records the granted scopes; the
createdBy field is a redacted email:
{
"eventAction": "CREATE",
"eventDomain": "API",
"interactionType": "WEB",
"user": "admin@example.com",
"resourceType": "API Key",
"resourceId": "skill2",
"resourceName": null,
"oldValue": null,
"newValue": {
"name": "skill2",
"scopes": ["risks:read", "audit:read", "products:write"],
"createdBy": "admi***@***le.com"
},
"apiTokenName": null
}
Interactive login (via WEB) — auth events carry no diff:
{
"eventAction": "LOGIN",
"eventDomain": "AUTHENTICATION",
"interactionType": "WEB",
"user": "admin@example.com",
"resourceType": "User",
"resourceId": "admin@example.com",
"resourceName": null,
"oldValue": null,
"newValue": null,
"apiTokenName": null
}
Self-referential audit-log read (via API) — every call to
GET /v1/audit-events produces one of these; newValue.eventCount is the
number of items that call returned (0 on empty pages, and yes, on the very
call that produced this entry):
{
"eventAction": "READ",
"eventDomain": "AUTHORIZATION",
"interactionType": "API",
"user": "admin@example.com",
"resourceType": "AuditEventList",
"resourceId": "great-rose-c73601",
"resourceName": "Audit Events",
"oldValue": null,
"newValue": { "eventCount": 10 },
"apiTokenName": "skill2"
}
Policy update (via WEB) — resourceId is the PolicySubType slug (not a
UUID), resourceName is typically null, and oldValue/newValue are the
full policy document (rules + conditions + triggers), not a diff. The example
below is truncated; a real policy body is easily 5–20 KB:
{
"eventAction": "UPDATE",
"eventDomain": "POLICY",
"interactionType": "WEB",
"user": "admin@example.com",
"resourceType": "Policy",
"resourceId": "code_risk",
"resourceName": null,
"oldValue": {
"sub": "code_risk",
"rules": [ /* full rule tree before the edit */ ]
},
"newValue": {
"sub": "code_risk",
"rules": [ /* full rule tree after the edit */ ]
},
"apiTokenName": null
}
Policy-specific gotchas:
Common Workflows
Workflow 1: Analyze Security Findings
1. GET /v1/risks/findings
2. Filter by severity and type
3. GET /v1/risks/findings/{id} (detailed info)
4. PUT /v1/risks/{id}/status (update status — OpenAPI calls the path param {sortKey}, but use the finding's `id` field)
Workflow 2: Create and Manage Product
1. POST /v1/products (create product)
2. POST /v1/products/{productId}/assets (add assets — repositories or org/group/workspace locations)
3. GET /v1/products/{productId} (verify setup)
4. Monitor findings via /v1/risks/findings (use ?product={productId} to scope)
Workflow 3: Permission Risk Assessment
1. GET /v1/risks/permissions/github/{type}
2. GET /v1/risks/hardening/github/stale/users
3. Identify excessive permissions
4. Recommend remediation
Workflow 4: Repository Inventory Check
1. GET /v1/inventory/repos
2. Compare with managed products
3. Add missing repos via POST /v1/products/{id}/assets
4. Update product coverage
Finding Triage & Review Workflow
Asset-based findings queries
Use repository and branch scoping when fetching findings for a specific codebase. The API accepts an asset query parameter in the form:
{scm}:{organization}/{project}/{repo}#{branch}
For GitHub, where there is no project segment, use an empty project field:
github:my-org//my-repo#main
For SCMs with a required project segment:
azure-devops:my-org/my-project/my-repo#main
Always URL-encode the asset string before placing it in the query
parameter. The value contains :, /, and especially # — and # is a
URL fragment delimiter, so an unencoded #main is silently stripped before
the request even leaves your machine (and shells eat it as a comment if
unquoted). Encode like this:
ASSET="github:my-org//my-repo#main"
ASSET_ENC=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=''))" "$ASSET")
curl -H "Authorization: Bearer $TOKEN" \
"https://api.app.arnica.io/v1/risks/findings?asset=${ASSET_ENC}&limit=20"
Common SCM formats
github:org//repo#branch
gitlab:group/subgroup/repo#branch or gitlab:group//repo#branch
azure-devops:org/project/repo#branch
bitbucket-cloud:workspace/project-key/repo#branch
bitbucket-server:https%3A%2F%2Fserver.fqdn.com/PROJ/repo#branch (verify per tenant — see note below)
Bitbucket-Server caveat: the bitbucket-server: form embeds a URL-encoded base URL as the "organization" segment (https%3A%2F%2F…). This pattern is not part of the OpenAPI spec and varies by tenant deployment. Before relying on it, confirm the exact format for your org by inspecting the asset field of an existing finding from a Bitbucket-Server repo:
# Find one BBS-backed finding and read its asset shape
curl -sS -H "Authorization: Bearer $ARNICA_API_TOKEN" \
"https://api.app.arnica.io/v1/risks/findings?limit=100" \
| jq '.[] | select(.asset.scm == "bitbucket-server") | .asset' | head -20
Then build the asset= query string from those exact scm/orgId/project/repo/branch values.
Deriving the asset string from a git repo
When you're working from a local checkout, derive the asset= query parameter
from git remote get-url origin. The arnica-fix skill uses this end-to-end
for triage workflows; the recipe is reusable any time you want to scope findings
to "the repo I'm currently in".
1. Get remote URL and current/default branch:
REMOTE_URL=$(git remote get-url origin)
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
DEFAULT_BRANCH=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null \
| sed 's|refs/remotes/origin/||')
# Fallback if origin/HEAD isn't set:
# git rev-parse --verify origin/main || git rev-parse --verify origin/master
2. Detect SCM type from the host portion of the URL:
| Host pattern | SCM type |
|---|
github.com | github |
dev.azure.com or *.visualstudio.com | azure-devops |
gitlab.com | gitlab |
bitbucket.org | bitbucket-cloud |
| Self-hosted Bitbucket / Stash | bitbucket-server |
| Self-hosted GitLab | gitlab |
For ambiguous self-hosted hosts (could be GitLab, Bitbucket Server, GitHub
Enterprise, …), build candidate asset strings for every plausible SCM type
and try each — the first one that returns non-empty results is the correct one.
Only fall back to asking the user when all candidates return zero.
3. Extract org / project / repo — rules differ per SCM:
GitHub (github.com/{org}/{repo}.git, no project segment):
ORG=$(echo "$REMOTE_URL" | sed -E 's|.*github\.com[:/]([^/]+)/.*|\1|')
REPO=$(echo "$REMOTE_URL" | sed -E 's|.*github\.com[:/][^/]+/([^/.]+)(\.git)?$|\1|')
PROJECT=""
Azure DevOps (dev.azure.com/{org}/{project}/_git/{repo}):
ORG=$(echo "$REMOTE_URL" | sed -E 's|.*dev\.azure\.com/([^/]+)/.*|\1|')
PROJECT=$(echo "$REMOTE_URL" | sed -E 's|.*dev\.azure\.com/[^/]+/([^/]+)/_git/.*|\1|')
REPO=$(echo "$REMOTE_URL" | sed -E 's|.*/_git/([^/.]+)(\.git)?$|\1|')
GitLab (gitlab.com/{group}[/{subgroup}…]/{repo}.git):
PATH_PART=$(echo "$REMOTE_URL" | sed -E 's|.*gitlab\.com[:/](.+?)(\.git)?$|\1|')
ORG=$(echo "$PATH_PART" | cut -d/ -f1)
REPO=$(echo "$PATH_PART" | rev | cut -d/ -f1 | rev)
PROJECT=$(echo "$PATH_PART" | sed -E "s|^$ORG/||; s|/$REPO$||") # may be empty
Bitbucket Cloud (bitbucket.org/{workspace}/{repo}.git): you typically
also need the project key from the Bitbucket UI — it isn't in the clone URL.
Bitbucket Server: org is the URL-encoded server base URL (see caveat
above); project is the PROJ segment; repo is the repo slug.
4. Assemble — always exactly 3 path segments (use empty string for missing
project, producing //):
ASSET="${SCM_TYPE}:${ORG}/${PROJECT}/${REPO}#${BRANCH}"
Worked examples:
| SCM | Asset string |
|---|
| GitHub | github:my-org//my-repo#main |
| Azure DevOps | azure-devops:Pasture/Goats/GitGoat#main |
| GitLab (top-level group) | gitlab:my-group//my-repo#main |
| GitLab (with subgroup) | gitlab:my-group/my-subgroup/my-repo#main |
| Bitbucket Cloud | bitbucket-cloud:workspace/project-key/my-repo#main |
| Bitbucket Server | bitbucket-server:https%3A%2F%2Fserver.fqdn.com/PROJ/my-repo#main |
Finding filters
Use exact match filters when querying findings. Full query-parameter list (from the live OpenAPI schema for GET /v1/risks/findings):
severity=critical|high|medium|low|info|unknown (exact match — info is informational, unknown is the scanner's "couldn't determine"; both are typically excluded from triage workflows)
type=SAST|SCA|IAC|LICENSE|REPUTATION|SECRET (uppercase)
status=<FindingStatuses> — see "Safe status updates" below for the full enum; use requires_review to focus on actionable issues
asset=<asset-string> to scope to a specific repo/branch
product=<productId> to scope to all assets of a product
sla=<bool> to filter by SLA breach
secretType=... and secretValidation=... for secret-finding refinement
expand=true for full details (caps page size at 20)
page / limit for pagination
Combine repeated parameters for multiple values:
severity=critical&severity=high&type=SAST&type=SCA
Expand and pagination
limit defaults to 20 and is capped at 100 server-side (values above 100 are silently clamped, not rejected). page defaults to 1.
When expand=true, the page size is further capped at 20 regardless of what you send for limit.
Always page through results until the returned page is shorter than the requested limit (i.e. len(page) < limit ⇒ last page). Don't rely on a total/count in the response — there isn't one.
Examples:
# Bulk listing (use limit=100 to minimize round-trips)
GET /v1/risks/findings?status=requires_review&limit=100&page=1
# Detailed listing (expand caps page size at 20)
# NOTE: the asset value MUST be URL-encoded — see the asset-encoding callout above.
# Encoded form of asset=github:my-org//my-repo#main:
GET /v1/risks/findings?asset=github%3Amy-org%2F%2Fmy-repo%23main&severity=high&status=requires_review&expand=true&limit=20&page=1
Response format
The findings endpoint returns a flat JSON array of finding objects, not a wrapped data object.
The asset object on each finding has this shape (verified against live responses):
{
"scm": "github",
"url": "https://github.com",
"orgId": "my-org",
"project": null,
"repo": "my-repo",
"branch": "main",
"path": "path/to/file.py",
"link": "https://github.com/my-org/my-repo/blob/main/path/to/file.py#L25"
}
Use asset.path, asset.repo, and asset.branch to correlate findings to source code, and asset.link for a direct deep link.
When expand=true, findings may include a details object with fields such as:
description
code
ruleId
references
package, packageType, version
recommendation
Safe status updates
Use the findings status endpoint carefully:
PUT /v1/risks/{id}/status
Content-Type: application/json
{
"status": "dismiss_not_accurate",
"reason": "False positive after triage"
}
The {id} path segment is the finding id from GET /v1/risks/findings
(the OpenAPI calls it {sortKey} but the top-level sortKey field on
responses is always null — use the id field). The id contains /, :,
@ and must be URL-encoded before being placed in the path. A successful
update returns HTTP 204 with no body.
Rollback: to undo a wrong dismissal or risk-acceptance, PUT
requires_review (it's in the write enum below). The finding goes back to
the triage queue.
Two related enums exist:
FindingStatuses (read enum — what you may see on a finding):
requires_review, pending_review, in_progress, awaiting_user_input,
risk_accepted, resolved, resolved_automatically,
dismissed_automatically, dismiss_no_capacity, dismiss_not_accurate,
dismiss_risk_is_tolerable, dismiss_secret_not_used,
dismiss_no_option_chosen_yet, mitigation_in_progress,
mitigation_pending_pr, mitigation_failed.
FindingUpdateDto.status (write enum — what you may send to PUT /v1/risks/{id}/status):
resolved
in_progress
requires_review
pending_review
risk_accepted
dismiss_no_capacity
dismiss_not_accurate
dismiss_risk_is_tolerable
dismiss_secret_not_used
Anything in the read enum but not the write enum is system-managed and cannot be set via the API (e.g. *_automatically, awaiting_user_input, mitigation_*, dismiss_no_option_chosen_yet).
Approval / triage guidance
For code-review and triage workflows, always verify findings before changing status.
When triaging:
- classify findings by severity and type
- review source correlation and
details
- only dismiss or accept risk with high confidence
- preserve findings for human review if uncertain
Required Scopes
risks:read - Read security findings
risks:write - Update finding status
products:read - List/view products
products:write - Create/update/delete products
inventory:read - View repository inventory
sbom-api:read - Check SBOM scan status
sbom-api:write - Upload SBOM scans
policies:read - View security policies
status-checks:read - View CI/CD status checks
audit:read - Read the organization audit-event log
Error Handling
Common HTTP Status Codes:
200 - Success
201 - Created
204 - No Content (e.g. successful PUT/DELETE)
400 - Bad Request (invalid parameters or body validation; also returned for malformed path params, e.g. a productId that isn't a valid UUIDv4)
403 - Forbidden — Arnica returns 403 for both invalid/expired tokens and for tokens missing the required scope. Treat 403 as "auth or scope problem"; the response body's message will distinguish (e.g. "Forbidden" vs. "…required scopes [products:write]").
404 - Not Found (resource missing, or a report has not been generated yet — see permission-risk endpoints)
409 - Conflict (e.g. duplicate product name, asset already attached)
429 - Rate Limited (back off and retry)
500 - Server Error
Error body shape (typical):
{
"ui": true,
"message": "human-readable message",
"error": "Forbidden | Bad Request | Not Found | …",
"statusCode": 403,
"id": "errid_…"
}
The id field (e.g. errid_80be5d1f-…) is a server-side correlation id you can quote when reporting issues to Arnica support.
Note: For most endpoints Arnica does not use HTTP 401 for invalid bearer tokens — it returns 403 Forbidden. The one documented exception is GET /v1/audit-events, which returns 401 for a missing API key (but still 403 for an invalid key or a key missing audit:read). Outside audit-events, don't write code that branches specifically on 401.
Tips & Best Practices
- Token Management: Use environment variables, never commit tokens
- Rate Limiting: On
429 responses, back off with exponential delay + jitter (suggested: start at 1s, double on each retry, max 30s, cap at 5 retries). When fanning out parallel requests (e.g. paginated expand=true listings), keep concurrency bounded — 5 in-flight requests is a safe default.
- Pagination: Use
offset/limit for /v1/products and /v1/audit-events, and page/limit for /v1/risks/findings (each endpoint's parameters are listed in its section above). For /v1/audit-events you can also page against the returned total; elsewhere, paginate until a page is shorter than the requested limit.
- Filtering: Filter on API side when possible, not in application
- Scopes: Request minimal required scopes for security
- Error Responses: Check response body for detailed error messages
- Testing: Start with GET requests to validate token and connectivity
- URL-encode dynamic path/query values: Asset strings, finding IDs, and any other value containing
:, /, #, @ must be URL-encoded before being interpolated into a URL. See the asset-encoding callout under "Asset-based findings queries".
Example: Get All Findings and Group by Severity
import requests
import os
API_TOKEN = os.getenv("ARNICA_API_TOKEN")
BASE_URL = "https://api.app.arnica.io"
headers = {
"Authorization": f"Bearer {API_TOKEN}",
"Content-Type": "application/json"
}
# Get all findings (note: response is a flat JSON array, not a wrapped object)
response = requests.get(
f"{BASE_URL}/v1/risks/findings",
headers=headers,
params={"status": "requires_review", "limit": 20, "page": 1},
)
response.raise_for_status()
findings = response.json() # list[dict]
# Group by severity (API returns lowercase severities)
by_severity = {}
for finding in findings:
severity = finding.get("severity", "unknown")
by_severity.setdefault(severity, []).append(finding)
# Print summary
for severity in ["critical", "high", "medium", "low"]:
count = len(by_severity.get(severity, []))
print(f"{severity}: {count} findings")
Resources