Snyk Hardening Guide
AppSec platform security for service accounts, SCM integrations, and Broker configs
Overview
Snyk provides developer security for vulnerability scanning across code, dependencies, containers, and IaC. REST API, CLI tokens, and SCM integrations access source code repositories and vulnerability data. Compromised access exposes vulnerability findings and potentially enables code access through integrations.
Intended Audience
- Security engineers managing AppSec tools
- DevSecOps administrators
- GRC professionals assessing development security
- Third-party risk managers evaluating security scanning tools
How to Use This Guide
- L1 (Crawl): Essential controls for all organizations
- L2 (Walk): Enhanced controls for security-sensitive environments
- L3 (Run): Strictest controls for regulated industries
Scope
This guide covers Snyk security configurations including authentication, access controls, and integration security.
Table of Contents
1. Authentication & Access Controls
1.1 Enforce SSO with MFA
Profile Level: L1 (Crawl) NIST 800-53: IA-2(1)
Description
Require SAML SSO through your corporate identity provider and enforce multi-factor authentication for every user who accesses the Snyk platform.
Rationale
Why This Matters:
- Centralizes Snyk authentication in your IdP so MFA, conditional access, and session policies apply to every login
- Snyk has no native password: users sign in with a GitHub, Google, Bitbucket, Entra ID, or Docker ID account or with company SSO, and accounts that bypass company SSO sit outside your IdP’s MFA, conditional-access, and deprovisioning controls
- SSO with automated deprovisioning removes departed users’ access immediately, preventing orphaned accounts from reaching vulnerability data
- Snyk holds your organization’s known-vulnerability inventory and SCM connections, so a single compromised login can reveal exactly where you are exploitable
Attack Prevented: Credential theft, phishing, MFA bypass, orphaned-account access
ClickOps Implementation
Step 1: Configure SAML SSO (Enterprise)
- Navigate to: Group → Settings → SSO (classic navigation) · Settings → Security and access → SSO at Group scope (new navigation)
- Copy the Entity ID, ACS URL, and Snyk signing certificate URL into your IdP’s SAML application; then enter the IdP sign-in URL, the IdP signing certificate, and your email domains in Snyk and select Create Connection
- Choose how new users join: Require an invite rather than Open to all
- Verify the connection with the direct login URL shown at the top of Step 3 before rolling it out
Step 2: Remove Non-SSO Access and Enforce MFA at the Identity Provider
- In Group → Members (classic navigation) · Settings → Security and access → Members at Group scope (new navigation), use the Auth column to find users who signed in with a social login (GitHub, Google, Bitbucket, Entra ID, or Docker ID) and remove them along with any personal accounts used during a pilot, so every remaining account is governed by your IdP
- Enforce MFA in your IdP for the Snyk application. Snyk has no Snyk-side MFA setting; on Free and Team plans (no SSO), MFA can only be enforced on the GitHub, Google, Bitbucket, Entra ID, or Docker ID account used to sign in
Code Implementation
Code Pack: API Script
if [ -z "${SNYK_TOKEN:-}" ]; then
echo "PRECONDITION: set SNYK_TOKEN - see the least-privilege note in this pack header" >&2; exit 2
fi
SNYK_API="${SNYK_API:-https://api.snyk.io}"
SNYK_API_VERSION="${SNYK_API_VERSION:-2024-10-15}"
SNYK_SSO_API_VERSION="${SNYK_SSO_API_VERSION:-2023-01-30~beta}"
UUID_RE='^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
MAX_PAGES="${SNYK_MAX_PAGES:-1000}"
# One GET that fails closed: a transport error or any HTTP status >= 400
# aborts the audit instead of being read as an empty result.
snyk_get() {
curl -sS -f \
--header "Authorization: token ${SNYK_TOKEN}" \
--header "Accept: application/vnd.api+json" \
"$1"
}
# links.next arrives as a string or as {href}, absolute or relative to /rest.
# A link that leaves the configured API host is refused: the token would go
# with it.
next_url() {
local next
next=$(printf '%s' "$1" | jq -r '.links.next // empty | if type == "object" then .href else . end') || return 1
case "${next}" in
"") ;;
"${SNYK_API}"/*) printf '%s' "${next}" ;;
http://* | https://*) echo "ERROR: refusing to follow a pagination link to another host" >&2; return 1 ;;
/rest/*) printf '%s' "${SNYK_API}${next}" ;;
/*) printf '%s' "${SNYK_API}/rest${next}" ;;
*) echo "ERROR: unrecognised pagination link" >&2; return 1 ;;
esac
}
# Print one jq projection per item across every page of a list endpoint.
# Returns 2 on any failed request or on a body that is not a JSON:API list.
snyk_list() {
local url="$1" projection="$2" page pages=0
while [ -n "${url}" ]; do
page=$(snyk_get "${url}") || { echo "ERROR: GET ${url%%\?*} did not succeed" >&2; return 2; }
printf '%s' "${page}" | jq -e '.data | type == "array"' >/dev/null \
|| { echo "ERROR: response is not the documented JSON:API list" >&2; return 2; }
printf '%s' "${page}" | jq -r ".data[] | ${projection}" || return 2
pages=$((pages + 1))
[ "${pages}" -lt "${MAX_PAGES}" ] || { echo "ERROR: pagination did not end after ${MAX_PAGES} pages" >&2; return 2; }
url=$(next_url "${page}") || return 2
done
}
audit_sso() {
local gid="$1" conns methods count
conns=$(snyk_list "${SNYK_API}/rest/groups/${gid}/sso_connections?version=${SNYK_SSO_API_VERSION}&limit=100" \
'.attributes.name // "(unnamed)"') || return 2
count=$(printf '%s' "${conns}" | grep -c . || true)
echo "== SSO connections for groups/${gid}: ${count} =="
[ -z "${conns}" ] || printf '%s\n' "${conns}" | sed 's/^/ - /'
methods=$(snyk_list "${SNYK_API}/rest/groups/${gid}/memberships?version=${SNYK_API_VERSION}&limit=100" \
'.relationships.user.data.attributes.login_method // "(not reported)"') || return 2
echo "== Group members by login method (count only) =="
if [ -n "${methods}" ]; then
printf '%s\n' "${methods}" | sort | uniq -c | sed 's/^ */ /'
else
echo " (no group memberships returned)"
fi
echo " Remove members who still sign in with social login or a personal account (Group -> Members)."
if [ "${count}" -eq 0 ]; then
echo "FINDING: groups/${gid} has no SSO connection - sign-in is not governed by your IdP"
return 1
fi
echo "OK: SSO is configured; confirm MFA is enforced for the Snyk app in your IdP."
}
if [ -z "${SNYK_GROUP_ID:-}" ]; then
echo "PRECONDITION: set SNYK_GROUP_ID - SSO is configured per Group" >&2; exit 2
fi
[[ "${SNYK_GROUP_ID}" =~ ${UUID_RE} ]] || { echo "ERROR: SNYK_GROUP_ID is not a UUID" >&2; exit 2; }
audit_sso "${SNYK_GROUP_ID}"
1.2 Role-Based Access
Profile Level: L1 (Crawl) NIST 800-53: AC-3, AC-6
Description
Assign Snyk group and organization members the least-privileged role required for their function instead of granting broad administrative access by default.
Rationale
Why This Matters:
- Least-privilege roles limit how much a single compromised or insider account can change, export, or expose
- Group Admin and Org Admin can alter integrations, ignore policies, and member access, so these rights should be tightly held
- The pre-defined Org Collaborator role can add and remove Projects and create, edit, and remove ignores, so developers who only need to view and test belong in a narrower custom role
- Clear role separation makes access reviews and audit attribution far easier across many organizations
Attack Prevented: Privilege escalation, insider misuse, unauthorized configuration changes, lateral movement
ClickOps Implementation
Step 1: Know the Roles
| Role | Scope | Key permissions |
|---|---|---|
| Group Admin | Group | Every permission in every Organization of the Group, including SSO, service accounts, and Group audit logs |
| Group Viewer | Group | Read-only access to the Group and to every Organization in it |
| Group Member | Group | Sees the Group’s list of Organizations only, until an Organization role is granted |
| Org Admin | Organization | Manage members, integrations, and service accounts; approve ignores |
| Org Collaborator | Organization | Add, test, and remove Projects; create, edit, and remove ignores; view Organization audit logs |
| Custom role (Enterprise) | Tenant, Group, or Organization | Built under Group → Settings → Member roles |
Tenant-level roles (Tenant Admin, Tenant Viewer, Tenant Member) do not grant Group or Organization access. Pre-defined role permissions cannot be edited; use a custom role to narrow them. See Pre-defined roles.
Step 2: Configure Organization Access
- Navigate to: Organization → Members (classic navigation) · Settings → Security and access → Members (new navigation)
- Click a member’s Role entry and select the least-privileged role the person needs
- Keep Group Admin and Org Admin to named owners, and review the member list on a fixed cadence
- Org Collaborator can ignore issues by default — restrict ignores to admins with control 3.2
Code Implementation
Code Pack: API Script
if [ -z "${SNYK_TOKEN:-}" ]; then
echo "PRECONDITION: set SNYK_TOKEN - see the least-privilege note in this pack header" >&2; exit 2
fi
SNYK_API="${SNYK_API:-https://api.snyk.io}"
SNYK_API_VERSION="${SNYK_API_VERSION:-2024-10-15}"
UUID_RE='^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
MAX_PAGES="${SNYK_MAX_PAGES:-1000}"
# One GET that fails closed: a transport error or any HTTP status >= 400
# aborts the audit instead of being read as an empty result.
snyk_get() {
curl -sS -f \
--header "Authorization: token ${SNYK_TOKEN}" \
--header "Accept: application/vnd.api+json" \
"$1"
}
# links.next arrives as a string or as {href}, absolute or relative to /rest.
# A link that leaves the configured API host is refused: the token would go
# with it.
next_url() {
local next
next=$(printf '%s' "$1" | jq -r '.links.next // empty | if type == "object" then .href else . end') || return 1
case "${next}" in
"") ;;
"${SNYK_API}"/*) printf '%s' "${next}" ;;
http://* | https://*) echo "ERROR: refusing to follow a pagination link to another host" >&2; return 1 ;;
/rest/*) printf '%s' "${SNYK_API}${next}" ;;
/*) printf '%s' "${SNYK_API}/rest${next}" ;;
*) echo "ERROR: unrecognised pagination link" >&2; return 1 ;;
esac
}
# Print one jq projection per item across every page of a list endpoint.
# Returns 2 on any failed request or on a body that is not a JSON:API list.
snyk_list() {
local url="$1" projection="$2" page pages=0
while [ -n "${url}" ]; do
page=$(snyk_get "${url}") || { echo "ERROR: GET ${url%%\?*} did not succeed" >&2; return 2; }
printf '%s' "${page}" | jq -e '.data | type == "array"' >/dev/null \
|| { echo "ERROR: response is not the documented JSON:API list" >&2; return 2; }
printf '%s' "${page}" | jq -r ".data[] | ${projection}" || return 2
pages=$((pages + 1))
[ "${pages}" -lt "${MAX_PAGES}" ] || { echo "ERROR: pagination did not end after ${MAX_PAGES} pages" >&2; return 2; }
url=$(next_url "${page}") || return 2
done
}
FINDINGS=0
# Review one scope: "orgs <org_id>" or "groups <group_id>".
audit_memberships() {
local scope="$1" scope_id="$2" rows total admins
rows=$(snyk_list "${SNYK_API}/rest/${scope}/${scope_id}/memberships?version=${SNYK_API_VERSION}&limit=100" \
'[(.relationships.role.data.attributes.name // "(no role)"),
(.relationships.user.data.attributes.username // "(unknown user)")] | @tsv') || return 2
total=$(printf '%s' "${rows}" | grep -c . || true)
admins=$(printf '%s\n' "${rows}" | awk -F '\t' 'tolower($1) ~ /admin/' | grep -c . || true)
echo "== ${scope}/${scope_id}: ${total} membership(s), ${admins} with an admin role =="
if [ "${total}" -gt 0 ]; then
echo "-- members per role:"
printf '%s\n' "${rows}" | cut -f1 | sort | uniq -c | sed 's/^ */ /'
echo "-- admins (review each against need):"
printf '%s\n' "${rows}" | awk -F '\t' 'tolower($1) ~ /admin/ { print " " $1 "\t" $2 }'
fi
if [ -n "${SNYK_MAX_ADMINS:-}" ] && [ "${admins}" -gt "${SNYK_MAX_ADMINS}" ]; then
echo "FINDING: ${scope}/${scope_id} has ${admins} admins, above SNYK_MAX_ADMINS=${SNYK_MAX_ADMINS}"
FINDINGS=$((FINDINGS + 1))
fi
}
if [ -z "${SNYK_GROUP_ID:-}" ] && [ -z "${SNYK_ORG_ID:-}" ]; then
echo "ERROR: set SNYK_ORG_ID and/or SNYK_GROUP_ID - nothing to review" >&2; exit 2
fi
if [ -n "${SNYK_MAX_ADMINS:-}" ] && ! [[ "${SNYK_MAX_ADMINS}" =~ ^[0-9]+$ ]]; then
echo "ERROR: SNYK_MAX_ADMINS must be a whole number" >&2; exit 2
fi
for scope in groups orgs; do
if [ "${scope}" = groups ]; then scope_id="${SNYK_GROUP_ID:-}"; else scope_id="${SNYK_ORG_ID:-}"; fi
[ -n "${scope_id}" ] || continue
[[ "${scope_id}" =~ ${UUID_RE} ]] || { echo "ERROR: the ${scope} id is not a UUID" >&2; exit 2; }
audit_memberships "${scope}" "${scope_id}"
done
[ "${FINDINGS}" -eq 0 ] || exit 1
2. Integration Security
2.1 Secure Service Account Tokens
Profile Level: L1 (Crawl) NIST 800-53: IA-5
Description
Choose the right service-account credential type for every non-interactive Snyk integration, and manage those credentials on a defined lifecycle. Snyk offers three service-account authentication types with materially different security properties — API key, access token, and OAuth 2.0 — and the choice determines whether the credential ever expires.
Rationale
Why This Matters:
- Service account credentials are non-interactive and bypass MFA, so a leaked credential grants direct API access with the service account’s full role
- API keys never expire. Snyk documents the API key service account as a legacy type and explicitly states it is not recommended — an API key leaked into a CI log or repository is a permanent credential until someone notices and deletes the account
- Access tokens cap out at a one-year maximum expiry, and Snyk documents no in-place rotation: when an access token expires you must create a new service account and re-plumb every consumer, so the rotation has to be planned rather than discovered at expiry
- OAuth 2.0 service accounts issue short-lived tokens with automated refresh, which is why they are the recommended type for CI/CD and any long-running integration
- Snyk credentials can read vulnerability findings and drive SCM operations, so exposure reveals exploitable weaknesses and integration reach
Attack Prevented: Token theft, credential leakage in CI/CD, standing-credential abuse, unauthorized data export
Attack Scenario: Exposed API token enables vulnerability data export; attackers gain insight into exploitable vulnerabilities before patches.
Prerequisites
- Enterprise plan — Snyk documents service accounts as available only on Enterprise plans; Free and Team organizations have personal user tokens only
ClickOps Implementation
Step 1: Choose the Credential Type
| Type | Expiry | Rotation | Snyk guidance |
|---|---|---|---|
| API key | Never expires | Manual only (delete + recreate) | Legacy — explicitly not recommended |
| Access token | Configurable, 1 year maximum | No in-place rotation — requires a new service account | Acceptable where OAuth is not supported; plan around the 1-year ceiling |
| OAuth 2.0 | Short-lived access token | Automated refresh | Recommended — use for CI/CD and all new integrations |
Default to OAuth 2.0. Never create API-key service accounts for new integrations, and treat any existing API-key service account as a standing credential to be migrated. See Choose a service account type.
Step 2: Audit Service Accounts
- Navigate to: Settings → Service accounts at Group or Organization level (classic navigation) · Group scope Settings → Security and access → Service accounts, Organization scope Settings → Organization settings → Service accounts (new navigation)
- Review all service accounts and record the credential type of each
- Remove unused accounts
- Flag every API-key service account for migration to OAuth 2.0
Step 3: Credential Lifecycle
- Create one service account per CI/CD pipeline or integration — never share credentials across consumers
- Assign the least-privileged role the integration needs
- For access tokens, diary the expiry date and schedule the replacement service account before it lapses (there is no in-place renewal)
- Store credentials in a secrets manager; never commit them or echo them in pipeline logs
Code Implementation
Code Pack: API Script
if [ -z "${SNYK_TOKEN:-}" ]; then
echo "PRECONDITION: set SNYK_TOKEN - see the least-privilege note in this pack header" >&2; exit 2
fi
SNYK_API="${SNYK_API:-https://api.snyk.io}"
SNYK_API_VERSION="${SNYK_API_VERSION:-2024-10-15}"
UUID_RE='^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
MAX_PAGES="${SNYK_MAX_PAGES:-1000}"
# One GET that fails closed: a transport error or any HTTP status >= 400
# aborts the audit instead of being read as an empty result.
snyk_get() {
curl -sS -f \
--header "Authorization: token ${SNYK_TOKEN}" \
--header "Accept: application/vnd.api+json" \
"$1"
}
# links.next arrives as a string or as {href}, absolute or relative to /rest.
# A link that leaves the configured API host is refused: the token would go
# with it.
next_url() {
local next
next=$(printf '%s' "$1" | jq -r '.links.next // empty | if type == "object" then .href else . end') || return 1
case "${next}" in
"") ;;
"${SNYK_API}"/*) printf '%s' "${next}" ;;
http://* | https://*) echo "ERROR: refusing to follow a pagination link to another host" >&2; return 1 ;;
/rest/*) printf '%s' "${SNYK_API}${next}" ;;
/*) printf '%s' "${SNYK_API}/rest${next}" ;;
*) echo "ERROR: unrecognised pagination link" >&2; return 1 ;;
esac
}
FINDINGS=0
# Audit one scope: "groups <group_id>" or "orgs <org_id>".
audit_service_accounts() {
local scope="$1" scope_id="$2" url page count=0 pages=0 n bad
url="${SNYK_API}/rest/${scope}/${scope_id}/service_accounts?version=${SNYK_API_VERSION}&limit=100"
echo "== Service accounts for ${scope}/${scope_id} =="
while [ -n "${url}" ]; do
page=$(snyk_get "${url}") \
|| { echo "ERROR: GET ${scope}/${scope_id}/service_accounts did not succeed" >&2; return 2; }
printf '%s' "${page}" | jq -e '.data | type == "array"' >/dev/null \
|| { echo "ERROR: response is not the documented JSON:API list" >&2; return 2; }
# auth_type is one of api_key, access_token, oauth_client_secret,
# oauth_private_key_jwt. Anything else is reported, never assumed safe.
printf '%s' "${page}" | jq -r '.data[] | .attributes |
[ .name,
(.auth_type // "(none)"),
(.access_token_expires_at // "n/a"),
(if .auth_type == "api_key" then
"FAIL: legacy API key - never expires, migrate to OAuth 2.0"
elif .auth_type == "access_token" then
"WARN: 1-year max expiry, no in-place rotation - plan replacement"
elif ((.auth_type // "") | startswith("oauth_")) then
"PASS: OAuth 2.0 short-lived credential"
else
"UNKNOWN: unrecognised auth_type - review"
end)
] | @tsv'
n=$(printf '%s' "${page}" | jq '.data | length')
bad=$(printf '%s' "${page}" | jq '[.data[] | (.attributes.auth_type // "")
| select(. == "api_key" or (. != "access_token" and (startswith("oauth_") | not)))] | length')
count=$((count + n))
FINDINGS=$((FINDINGS + bad))
pages=$((pages + 1))
[ "${pages}" -lt "${MAX_PAGES}" ] || { echo "ERROR: pagination did not end after ${MAX_PAGES} pages" >&2; return 2; }
url=$(next_url "${page}") || return 2
done
echo "-- ${count} service account(s) audited in ${scope}/${scope_id}"
}
# Remove a flagged legacy service account once its workload is migrated to an
# OAuth 2.0 service account (deletion also kills its API key). Opt-in only:
# --delete <orgs|groups> <scope_id> <service_account_id>
delete_service_account() {
local scope="$1" scope_id="$2" sa_id="$3"
case "${scope}" in
orgs | groups) ;;
*) echo "ERROR: scope must be orgs or groups" >&2; return 2 ;;
esac
if ! [[ "${scope_id}" =~ ${UUID_RE} && "${sa_id}" =~ ${UUID_RE} ]]; then
echo "ERROR: scope_id and service_account_id must be UUIDs" >&2; return 2
fi
curl -sS -f -X DELETE \
--header "Authorization: token ${SNYK_TOKEN}" \
"${SNYK_API}/rest/${scope}/${scope_id}/service_accounts/${sa_id}?version=${SNYK_API_VERSION}" >/dev/null \
|| { echo "ERROR: DELETE did not succeed for ${scope}/${scope_id} service account ${sa_id}" >&2; return 2; }
echo "Deleted service account ${sa_id} from ${scope}/${scope_id}"
}
case "${1:-}" in
--delete)
if [ "$#" -ne 4 ]; then
echo "Usage: $0 --delete <orgs|groups> <scope_id> <service_account_id>" >&2; exit 2
fi
delete_service_account "$2" "$3" "$4"
exit 0 ;;
"") ;;
*) echo "Usage: $0 [--delete <orgs|groups> <scope_id> <service_account_id>]" >&2; exit 2 ;;
esac
if [ -z "${SNYK_GROUP_ID:-}" ] && [ -z "${SNYK_ORG_ID:-}" ]; then
echo "ERROR: set SNYK_GROUP_ID and/or SNYK_ORG_ID - nothing to audit" >&2; exit 2
fi
for scope in groups orgs; do
if [ "${scope}" = groups ]; then scope_id="${SNYK_GROUP_ID:-}"; else scope_id="${SNYK_ORG_ID:-}"; fi
[ -n "${scope_id}" ] || continue
[[ "${scope_id}" =~ ${UUID_RE} ]] || { echo "ERROR: the ${scope} id is not a UUID" >&2; exit 2; }
audit_service_accounts "${scope}" "${scope_id}"
done
if [ "${FINDINGS}" -gt 0 ]; then
echo "FINDING: ${FINDINGS} service account(s) use an api_key or an unrecognised auth_type"
exit 1
fi
echo "No api_key or unrecognised service-account credential types found."
2.2 SCM Integration Security
Profile Level: L1 (Crawl) NIST 800-53: CM-7
Description
Review and restrict Snyk’s source-code-management integrations so each connection has only the repository access it needs, and route private-repo access through the Snyk Broker. Snyk ships the Broker in two deployment models — Universal Broker and Classic Broker — and new deployments should target Universal Broker.
Rationale
Why This Matters:
- SCM integrations grant Snyk read access to source repositories, so an over-scoped or stale connection widens what a platform compromise can reach
- The Snyk Broker keeps private repositories behind your perimeter and brokers only approved requests instead of exposing direct SCM credentials — Snyk documents that with Broker, “credentials remain within your network and are never stored by or transmitted to Snyk”
- Universal Broker consolidates many connection types (GitHub, GitLab, Artifactory, Jira, container registry) behind a single client or set of replicas, so there is one hardened egress path to govern instead of one Broker deployment per integration
- Classic Broker uses per-integration deployments that pass only requests on a default approved-data list (tuned with ACCEPT rule flags); a custom
accept.jsonreplaces that list and disables the ACCEPT flags, so keeping it minimal becomes your job - Limiting repository scope contains the impact if a token or integration is abused, preventing access to unrelated codebases
Attack Prevented: Source code exposure, over-scoped integration abuse, supply chain reconnaissance, credential leakage
Changed default (April 2026): Snyk Broker now runs in high-availability mode by default. Deployments provisioned before this change may still be running single-instance; confirm your replica configuration rather than assuming the old default. Source: Snyk What’s New.
ClickOps Implementation
Step 1: Review Integrations
- Navigate to: Settings → Integrations (classic and new navigation; Broker connections are under Settings → Integrations → Snyk Broker in the new navigation)
- Open each SCM integration (for example Settings → Integrations → GitHub) and confirm it is still needed
- Under Repository access, clear Allow access to private repositories unless Snyk must scan private repositories. The integration page offers no per-repository allow-list, so narrow which repositories it can reach on the SCM side (the GitHub App installation or the integration token’s scope)
- Under Settings → Integrations → General, decide deliberately whether Enable Workspaces is acceptable: it lets Snyk store shallow temporary clones of your source repositories
Step 2: Choose a Broker Deployment Model (Enterprise)
| Model | Shape | Use it for |
|---|---|---|
| Universal Broker | One Broker client (or replica set) serving many connection types — GitHub, GitLab, Artifactory, Jira, container registry | New deployments. Fewer moving parts, one egress path, centrally managed connections |
| Classic Broker | One Broker deployment per integration type, each with its own approved-data list | Existing estates already running per-integration Brokers |
Step 3: Harden the Broker Deployment
- Deploy the Broker inside your network so SCM credentials never leave your perimeter
- Restrict the permitted request set — in Classic Broker, the default approved-data list tuned with ACCEPT rule flags, or a custom
accept.jsonthat replaces it (and disables those flags); in Universal Broker, per-connection configuration - Limit exposed endpoints to the minimum the Snyk integration requires
- Verify high-availability replica count matches your availability requirement
Code Implementation
Code Pack: API Script
if [ -z "${SNYK_TOKEN:-}" ]; then
echo "PRECONDITION: set SNYK_TOKEN - see the least-privilege note in this pack header" >&2; exit 2
fi
SNYK_API="${SNYK_API:-https://api.snyk.io}"
SNYK_API_VERSION="${SNYK_API_VERSION:-2024-10-15}"
UUID_RE='^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
MAX_PAGES="${SNYK_MAX_PAGES:-1000}"
# One GET that fails closed: a transport error or any HTTP status >= 400
# aborts the audit instead of being read as an empty result.
snyk_get() {
curl -sS -f \
--header "Authorization: token ${SNYK_TOKEN}" \
--header "Accept: application/vnd.api+json" \
"$1"
}
# links.next arrives as a string or as {href}, absolute or relative to /rest.
# A link that leaves the configured API host is refused: the token would go
# with it.
next_url() {
local next
next=$(printf '%s' "$1" | jq -r '.links.next // empty | if type == "object" then .href else . end') || return 1
case "${next}" in
"") ;;
"${SNYK_API}"/*) printf '%s' "${next}" ;;
http://* | https://*) echo "ERROR: refusing to follow a pagination link to another host" >&2; return 1 ;;
/rest/*) printf '%s' "${SNYK_API}${next}" ;;
/*) printf '%s' "${SNYK_API}/rest${next}" ;;
*) echo "ERROR: unrecognised pagination link" >&2; return 1 ;;
esac
}
# Print one jq projection per item across every page of a list endpoint.
# Returns 2 on any failed request or on a body that is not a JSON:API list.
snyk_list() {
local url="$1" projection="$2" page pages=0
while [ -n "${url}" ]; do
page=$(snyk_get "${url}") || { echo "ERROR: GET ${url%%\?*} did not succeed" >&2; return 2; }
printf '%s' "${page}" | jq -e '.data | type == "array"' >/dev/null \
|| { echo "ERROR: response is not the documented JSON:API list" >&2; return 2; }
printf '%s' "${page}" | jq -r ".data[] | ${projection}" || return 2
pages=$((pages + 1))
[ "${pages}" -lt "${MAX_PAGES}" ] || { echo "ERROR: pagination did not end after ${MAX_PAGES} pages" >&2; return 2; }
url=$(next_url "${page}") || return 2
done
}
inventory_connections() {
local org_id="$1" rows total types disallowed="" rc=0
rows=$(snyk_list "${SNYK_API}/rest/orgs/${org_id}/brokers/connections?version=${SNYK_API_VERSION}&limit=100" \
'[(.attributes.connection_type // "(none)"), (.attributes.name // "(unnamed)"),
(.attributes.deployment_id // "(none)")] | @tsv') || return 2
total=$(printf '%s' "${rows}" | grep -c . || true)
echo "== Broker connections for orgs/${org_id}: ${total} =="
echo "-- connection_type / name / deployment_id:"
[ "${total}" -eq 0 ] || printf '%s\n' "${rows}" | sed 's/^/ /'
if [ -n "${SNYK_ALLOWED_CONNECTION_TYPES:-}" ] && [ "${total}" -gt 0 ]; then
types=$(printf '%s\n' "${rows}" | cut -f1 | sort -u) && [ -n "${types}" ] \
|| { echo "ERROR: could not list the connection types to compare" >&2; return 2; }
disallowed=$(printf '%s\n' "${types}" \
| grep -vxF -f <(printf '%s\n' "${SNYK_ALLOWED_CONNECTION_TYPES}" | tr ',' '\n' | tr -d '[:blank:]')) || rc=$?
# grep exits 1 when every type is on the allow-list. Any other failure
# means the comparison did not run, which must not read as "all allowed".
if [ "${rc}" -gt 1 ]; then
echo "ERROR: allow-list comparison failed (grep exit ${rc})" >&2; return 2
fi
fi
if [ -n "${disallowed}" ]; then
echo "FINDING: connection types outside SNYK_ALLOWED_CONNECTION_TYPES:"
printf '%s\n' "${disallowed}" | sed 's/^/ /'
return 1
fi
}
inventory_deployments() {
local tenant_id="$1" rows total
rows=$(snyk_list "${SNYK_API}/rest/tenants/${tenant_id}/brokers/deployments?version=${SNYK_API_VERSION}&limit=100" \
'[.id, (.attributes.broker_app_installed_in_org_id // "(none)"), (.attributes.updated_at // "n/a")] | @tsv') || return 2
total=$(printf '%s' "${rows}" | grep -c . || true)
echo "== Broker deployments for tenants/${tenant_id}: ${total} =="
echo "-- deployment_id / installed_in_org / updated_at:"
[ "${total}" -eq 0 ] || printf '%s\n' "${rows}" | sed 's/^/ /'
}
if [ -z "${SNYK_ORG_ID:-}" ]; then
echo "PRECONDITION: set SNYK_ORG_ID - Broker connections are listed per Organization" >&2; exit 2
fi
[[ "${SNYK_ORG_ID}" =~ ${UUID_RE} ]] || { echo "ERROR: SNYK_ORG_ID is not a UUID" >&2; exit 2; }
if [ -n "${SNYK_TENANT_ID:-}" ]; then
[[ "${SNYK_TENANT_ID}" =~ ${UUID_RE} ]] || { echo "ERROR: SNYK_TENANT_ID is not a UUID" >&2; exit 2; }
inventory_deployments "${SNYK_TENANT_ID}"
fi
inventory_connections "${SNYK_ORG_ID}"
3. Data Security
3.1 Project Visibility
Profile Level: L1 (Crawl) NIST 800-53: AC-21
Description
Limit who can see vulnerability findings, Project history, and reports by controlling Organization membership and, on Enterprise plans, the view permissions in custom roles. Snyk documents no per-Project visibility setting, so the Organization is the visibility boundary.
Rationale
Why This Matters:
- Vulnerability findings describe exactly where your software is exploitable, so over-broad visibility hands attackers a roadmap
- Limiting who can view issue details and share findings keeps sensitive security data on a need-to-know basis
- Controlling report generation and export prevents bulk exfiltration of findings outside monitored channels
- Auditing report access lets you detect unusual harvesting of vulnerability data before it is misused
Attack Prevented: Information disclosure, vulnerability reconnaissance, data exfiltration, insider leakage
ClickOps Implementation
Step 1: Scope Visibility with Organizations
- Every pre-defined Organization role can view the Organization’s Projects, ignores, and reports. Put sensitive repositories in a dedicated Organization and invite only the people who need them
- Navigate to: Organization → Members (classic navigation) · Settings → Security and access → Members (new navigation), and remove anyone who no longer needs access
- In Settings → Organization settings → General → Requesting Access, disable access requests for Organizations that hold sensitive repositories, so users outside the Organization cannot ask to join it
Step 2: Narrow Read Rights with Custom Roles (Enterprise)
- Navigate to: Group → Settings → Member roles (classic navigation) · Settings → Security and access → Member roles at Group scope (new navigation)
- Create a role without View Organization reports and View Project History for members who only need to run tests, and assign it in place of Org Collaborator
Step 3: Report Access
- Keep Edit Organization reports with Org Admins (Org Collaborators cannot edit reports by default), and add report permissions to custom roles only where a person needs them
- Review who holds report permissions in every access review (control 1.2)
Code Implementation
Code Pack: API Script
if [ -z "${SNYK_TOKEN:-}" ]; then
echo "PRECONDITION: set SNYK_TOKEN - see the least-privilege note in this pack header" >&2; exit 2
fi
SNYK_API="${SNYK_API:-https://api.snyk.io}"
SNYK_API_VERSION="${SNYK_API_VERSION:-2024-10-15}"
SNYK_ROLES_API_VERSION="${SNYK_ROLES_API_VERSION:-2024-10-15~beta}"
UUID_RE='^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
MAX_PAGES="${SNYK_MAX_PAGES:-1000}"
# One GET that fails closed: a transport error or any HTTP status >= 400
# aborts the audit instead of being read as an empty result.
snyk_get() {
curl -sS -f \
--header "Authorization: token ${SNYK_TOKEN}" \
--header "Accept: application/vnd.api+json" \
"$1"
}
# links.next arrives as a string or as {href}, absolute or relative to /rest.
# A link that leaves the configured API host is refused: the token would go
# with it.
next_url() {
local next
next=$(printf '%s' "$1" | jq -r '.links.next // empty | if type == "object" then .href else . end') || return 1
case "${next}" in
"") ;;
"${SNYK_API}"/*) printf '%s' "${next}" ;;
http://* | https://*) echo "ERROR: refusing to follow a pagination link to another host" >&2; return 1 ;;
/rest/*) printf '%s' "${SNYK_API}${next}" ;;
/*) printf '%s' "${SNYK_API}/rest${next}" ;;
*) echo "ERROR: unrecognised pagination link" >&2; return 1 ;;
esac
}
# Print one jq projection per item across every page of a list endpoint.
# Returns 2 on any failed request or on a body that is not a JSON:API list.
snyk_list() {
local url="$1" projection="$2" page pages=0
while [ -n "${url}" ]; do
page=$(snyk_get "${url}") || { echo "ERROR: GET ${url%%\?*} did not succeed" >&2; return 2; }
printf '%s' "${page}" | jq -e '.data | type == "array"' >/dev/null \
|| { echo "ERROR: response is not the documented JSON:API list" >&2; return 2; }
printf '%s' "${page}" | jq -r ".data[] | ${projection}" || return 2
pages=$((pages + 1))
[ "${pages}" -lt "${MAX_PAGES}" ] || { echo "ERROR: pagination did not end after ${MAX_PAGES} pages" >&2; return 2; }
url=$(next_url "${page}") || return 2
done
}
# Everyone listed here can read this Organization's Projects, issues and ignores.
org_viewers() {
local org_id="$1" roles total
roles=$(snyk_list "${SNYK_API}/rest/orgs/${org_id}/memberships?version=${SNYK_API_VERSION}&limit=100" \
'.relationships.role.data.attributes.name // "(no role)"') || return 2
total=$(printf '%s' "${roles}" | grep -c . || true)
echo "== orgs/${org_id}: ${total} member(s) can view its Projects and findings =="
[ "${total}" -eq 0 ] || printf '%s\n' "${roles}" | sort | uniq -c | sed 's/^ */ /'
}
# Which Tenant roles carry the permissions that expose findings. A role whose
# permission list comes back empty is reported as such, never as "no access".
role_view_permissions() {
local tenant_id="$1"
echo "== Tenant roles: view permissions (projects / project history / org reports) =="
snyk_list "${SNYK_API}/rest/tenants/${tenant_id}/roles?version=${SNYK_ROLES_API_VERSION}&limit=100" \
'.attributes | [ .name,
(if .custom then "custom" else "pre-defined" end),
(if ((.permissions // []) | length) == 0 then "permissions not returned - review in the console"
else "org.project.read=" + (if (.permissions | any(. == "org.project.read")) then "yes" else "no" end)
+ " org.project.snapshot.read=" + (if (.permissions | any(. == "org.project.snapshot.read")) then "yes" else "no" end)
+ " org.report.read=" + (if (.permissions | any(. == "org.report.read")) then "yes" else "no" end)
end) ] | @tsv' | sed 's/^/ /'
}
if [ -z "${SNYK_ORG_ID:-}" ]; then
echo "PRECONDITION: set SNYK_ORG_ID - visibility is decided per Organization" >&2; exit 2
fi
[[ "${SNYK_ORG_ID}" =~ ${UUID_RE} ]] || { echo "ERROR: SNYK_ORG_ID is not a UUID" >&2; exit 2; }
org_viewers "${SNYK_ORG_ID}"
if [ -n "${SNYK_TENANT_ID:-}" ]; then
[[ "${SNYK_TENANT_ID}" =~ ${UUID_RE} ]] || { echo "ERROR: SNYK_TENANT_ID is not a UUID" >&2; exit 2; }
role_view_permissions "${SNYK_TENANT_ID}"
fi
3.2 Ignore Policy
Profile Level: L2 (Walk) NIST 800-53: CM-7
Description
Govern how vulnerabilities are ignored by requiring a documented reason, an expiration date, and periodic review of all suppressed findings.
Rationale
Why This Matters:
- Unbounded ignores silently suppress real vulnerabilities, letting exploitable issues ship while dashboards appear clean
- Requiring a reason and approver creates accountability and an audit trail for every accepted risk
- Expiration forces re-evaluation so a temporary exception does not quietly become permanent blindness
- Auditing ignored issues catches abuse where suppression is used to bypass security gates rather than manage genuine false positives
Attack Prevented: Risk-acceptance abuse, suppressed-vulnerability exploitation, security-gate bypass, audit evasion
ClickOps Implementation
Step 1: Restrict Who Can Ignore and Require a Reason
- Navigate to: Organization → Settings → General → Ignores (classic navigation) · Organization scope Settings → Organization settings → General → Ignores (new navigation)
- Under Manage ignores for projects not using Code Consistent Ignores using Snyk UI or API, select Group and Org Admin (Default user roles only). The other option, All user roles with Project Ignores permissions, lets every role holding the ignore permissions manage ignores; Snyk’s pre-defined roles give Org Collaborator Create, Edit, and Remove Ignores, so under that option Collaborators can suppress findings. Snyk’s documentation presents admin-only as a restriction you opt into
- Under Ignore issues using Snyk CLI or the .snyk file, select Snyk app and API only so
.snykfile ignores in a repository are not applied. Ignores managed in Snyk, including those created through the CLI, still apply - Under Require reason for each ignore, select Required (only applies to ignores in the Snyk app, not the CLI or API). Enforce reasons for CLI and API ignores in pipeline review, because this setting does not reach them
Step 2: Approval Workflow for Snyk Code (Optional)
- Consistent Ignores appears under Ignores as Enable ignoring across the repository (Snyk Code and Snyk Secrets); when it is switched on at the Group level, it can only be switched off there
- With it on, in Organization → Settings (classic navigation) · Settings → Organization settings → General at Organization scope (new navigation), turn on Ignore approval workflow → Enable ignore approval workflow (Organization level only), and leave Allow Ignore Reviewers to ignore issues without requiring approval off
- Projects using Consistent Ignores are outside the Step 1 restriction, which applies only to projects not using it, so rely on the approval workflow for them
Step 3: Bound and Audit Every Ignore
- Always set an expiry. CLI ignores default to 30 days, and a
.snykexpires:value that is not inYYYY-MM-DDThh:mm:ss.fffZform makes the ignore persist indefinitely - Review ignored issues on a fixed cadence
Code Implementation
Code Pack: CLI Script
# Every ignore lands in the repo's .snyk policy file as:
# ignore:
# '<ISSUE_ID>':
# - '*':
# reason: <REASON>
# expires: <EXPIRY>
# Snyk enforces an expiry only when it is a JavaScript Date Time String
# (YYYY-MM-DDThh:mm:ss.fffZ). Any other value makes the ignore persist
# indefinitely, so a malformed expiry is a finding, not a formatting nit.
POLICY_FILE="${SNYK_POLICY_FILE:-.snyk}"
EXPIRES_RE='^[0-9]+: [0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{3})?Z$'
# Count lines of one kind inside the top-level `ignore:` block only.
count_in_ignore_block() {
awk -v pat="$1" '/^ignore:/ { inb = 1; next }
/^[^[:space:]#]/ { inb = 0 }
inb && $0 ~ pat { n++ }
END { print n + 0 }' "${POLICY_FILE}"
}
audit_policy_file() {
if [ ! -f "${POLICY_FILE}" ]; then
echo "PRECONDITION: no policy file at ${POLICY_FILE} - nothing was audited." >&2
echo " A repo with no CLI ignores has none; set SNYK_POLICY_FILE to audit another path." >&2
return 2
fi
local entries reasons expiries bad rc=0
entries=$(count_in_ignore_block '^[[:space:]]*- ')
reasons=$(count_in_ignore_block '^[[:space:]]*reason:')
expiries=$(count_in_ignore_block '^[[:space:]]*expires:')
echo "== Suppression entries in ${POLICY_FILE} =="
grep -nE '^[[:space:]]+(reason|expires):' "${POLICY_FILE}" || echo "No reason/expires lines recorded"
echo "ignore entries: ${entries} | reasons: ${reasons} | expiries: ${expiries}"
# Every expires: value inside the ignore block, quotes stripped, prefixed
# with its line number; anything not in Date Time String form is a finding.
bad=$(awk '/^ignore:/ { inb = 1; next }
/^[^[:space:]#]/ { inb = 0 }
inb && /^[[:space:]]*expires:/ {
v = $0
sub(/^[[:space:]]*expires:[[:space:]]*/, "", v)
gsub(/["\047]/, "", v)
sub(/[[:space:]]+$/, "", v)
print NR ": " v
}' "${POLICY_FILE}" | grep -vE "${EXPIRES_RE}" || true)
if [ -n "${bad}" ]; then
echo "FAIL: malformed expiry - Snyk treats these ignores as never-expiring:"
printf '%s\n' "${bad}"
rc=1
fi
if [ "${expiries}" -ne "${entries}" ]; then
echo "FAIL: ${entries} ignore entries but ${expiries} expiries - an entry without expires: never expires"
rc=1
fi
if [ "${reasons}" -ne "${entries}" ]; then
echo "FAIL: ${entries} ignore entries but ${reasons} reasons - every ignore needs a recorded reason"
rc=1
fi
[ "${rc}" -ne 0 ] || echo "OK: every ignore entry has a reason and an enforceable expiry"
return "${rc}"
}
# Never ignore without a reason, an accountable approver and a bounded expiry.
# Left alone, the CLI default expiry is 30 days - set it explicitly so the
# review date is a decision, not an accident. Expiry format: YYYY-MM-DD.
DATE_RE='^[0-9]{4}-[0-9]{2}-[0-9]{2}$'
governed_ignore() {
local issue_id="$1" expiry="$2" reason
shift 2
reason="$*"
command -v snyk >/dev/null || { echo "PRECONDITION: snyk CLI not found (npm i -g snyk)" >&2; return 2; }
if [ -z "${SNYK_IGNORE_APPROVER:-}" ]; then
echo "PRECONDITION: set SNYK_IGNORE_APPROVER to the person accountable for this risk acceptance" >&2
return 2
fi
[[ "${expiry}" =~ ${DATE_RE} ]] || { echo "ERROR: expiry must be YYYY-MM-DD" >&2; return 2; }
[ -n "${reason}" ] || { echo "ERROR: a human-readable reason is required" >&2; return 2; }
snyk ignore \
--id="${issue_id}" \
--expiry="${expiry}" \
--reason="${reason} (approved-by: ${SNYK_IGNORE_APPROVER})"
}
usage() {
echo "Usage: $0 --audit-only" >&2
echo " $0 <snyk-issue-id> <expiry YYYY-MM-DD> <reason...>" >&2
}
case "${1:-}" in
--audit-only)
audit_policy_file ;;
"" | -h | --help)
usage; exit 2 ;;
*)
if [ "$#" -lt 3 ]; then usage; exit 2; fi
governed_ignore "$@"
audit_policy_file ;;
esac
4. Monitoring & Detection
4.1 Audit Logs (Enterprise)
Profile Level: L1 (Crawl) NIST 800-53: AU-2, AU-3
Description
Review Snyk audit logs and forward them to your SIEM to retain a record of user and administrative activity across the platform. Snyk’s audit logs are an Enterprise-plan capability with a 90-day rolling retention window, and they exclude login and logout events — both facts drive how the control must be implemented.
Rationale
Why This Matters:
- Audit logs provide the authoritative record of who changed integrations, roles, ignore policies, and tokens
- Retention is a rolling 90 days. Any investigation, compliance evidence, or retrospective beyond that window is impossible from Snyk alone, which makes SIEM forwarding mandatory rather than optional
- Login and logout events are excluded from the audit-log endpoints. Authentication activity must be reconstructed from your identity provider’s logs — treat the IdP as the system of record for Snyk sign-in, and correlate it with Snyk audit events in the SIEM
- Without centralized logging, account compromise and configuration tampering can go undetected until damage is done
- Reviewing activity supports incident response, forensics, and compliance evidence for access and change controls
Attack Prevented: Undetected account compromise, configuration tampering, audit gaps, delayed incident response
Prerequisites
- Enterprise plan — audit logs are not available on Free or Team
ClickOps Implementation
Console location unconfirmed: Snyk documents audit-log retrieval for the Snyk platform through the API only. Its audit-log how-to covers the Group and Organization search endpoints, and its navigation map lists no Audit logs page at Tenant, Group, or Organization scope. The Web UI audit-log reports Snyk also documents belong to Snyk API & Web, a separate product. Plan on the API until your Enterprise console shows a page.
Step 1: Retrieve and Review Audit Logs
- Search the Group’s and each Organization’s audit logs through the API — Step 2 schedules this, and the read-only export under Code Implementation below runs it
- Review user activities against the Detection Focus list below
Step 2: Forward to SIEM Before the 90-Day Window Closes
- Pull group- and org-level audit events via the audit logs API
- Schedule collection at an interval well inside the 90-day retention window so no events age out uncollected
- Retain forwarded events in the SIEM per your own retention policy — Snyk will not hold them
Step 3: Fill the Authentication Gap from the IdP
- Forward Snyk SSO sign-in and sign-out events from your identity provider (the Snyk audit endpoints do not carry them)
- Correlate IdP authentication events with Snyk audit events to reconstruct a complete session-to-action trail
Code Implementation
Code Pack: API Script
if [ -z "${SNYK_TOKEN:-}" ]; then
echo "PRECONDITION: set SNYK_TOKEN - see the least-privilege note in this pack header" >&2; exit 2
fi
SNYK_API="${SNYK_API:-https://api.snyk.io}"
SNYK_API_VERSION="${SNYK_API_VERSION:-2024-10-15}"
UUID_RE='^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
MAX_PAGES="${SNYK_MAX_PAGES:-1000}"
# One GET that fails closed: a transport error or any HTTP status >= 400
# aborts the export instead of being read as an empty result.
snyk_get() {
curl -sS -f \
--header "Authorization: token ${SNYK_TOKEN}" \
--header "Accept: application/vnd.api+json" \
"$1"
}
# The next page is requested by re-issuing the ORIGINAL query plus the cursor
# taken from links.next (a string or {href}). Following the link verbatim
# would trust it to carry from/to; if it did not, page 2 would silently fall
# back to the API's default window. Only the cursor value is taken, so the
# token never goes anywhere but the configured API host.
next_cursor() {
local next cursor
next=$(printf '%s' "$1" | jq -r '.links.next // empty | if type == "object" then .href else . end') || return 1
[ -n "${next}" ] || return 0
cursor=$(printf '%s' "${next}" | sed -n 's/.*[?&]cursor=\([^&]*\).*/\1/p')
[ -n "${cursor}" ] || { echo "ERROR: links.next carries no cursor parameter" >&2; return 1; }
printf '%s' "${cursor}"
}
# Collection window in UTC RFC3339, as the API requires. The default is the
# whole previous UTC day (from is inclusive, to is exclusive). Run at least
# daily: events age out after 90 days. GNU date first, BSD/macOS date second.
FROM="${SNYK_AUDIT_FROM:-$(date -u -d '1 day ago' +%Y-%m-%dT00:00:00Z 2>/dev/null || date -u -v-1d +%Y-%m-%dT00:00:00Z)}"
TO="${SNYK_AUDIT_TO:-$(date -u +%Y-%m-%dT00:00:00Z)}"
RFC3339_RE='^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$'
if ! [[ "${FROM}" =~ ${RFC3339_RE} && "${TO}" =~ ${RFC3339_RE} ]]; then
echo "ERROR: SNYK_AUDIT_FROM and SNYK_AUDIT_TO must be UTC RFC3339, e.g. 2026-01-02T00:00:00Z" >&2
exit 2
fi
OUT_DIR="${SNYK_AUDIT_OUT_DIR:-./snyk-audit-logs}"
# Export one scope: "orgs <org_id>" or "groups <group_id>".
export_audit_logs() {
local scope="$1" scope_id="$2" base url page cursor count=0 pages=0 n
local out="${OUT_DIR}/${scope}-${scope_id}-${FROM//:/}_${TO//:/}.ndjson"
base="${SNYK_API}/rest/${scope}/${scope_id}/audit_logs/search?version=${SNYK_API_VERSION}&from=${FROM}&to=${TO}&size=100&sort_order=ASC"
url="${base}"
: >"${out}.partial" \
|| { echo "ERROR: cannot write to SNYK_AUDIT_OUT_DIR (${OUT_DIR})" >&2; return 2; }
while [ -n "${url}" ]; do
page=$(snyk_get "${url}") \
|| { rm -f "${out}.partial"; echo "ERROR: audit-log request did not succeed for ${scope}/${scope_id}" >&2; return 2; }
printf '%s' "${page}" | jq -e '.data.items | type == "array"' >/dev/null \
|| { rm -f "${out}.partial"; echo "ERROR: response is not the documented audit-log shape" >&2; return 2; }
printf '%s' "${page}" | jq -c '.data.items[]' >>"${out}.partial" \
|| { rm -f "${out}.partial"; return 2; }
n=$(printf '%s' "${page}" | jq '.data.items | length')
count=$((count + n))
pages=$((pages + 1))
[ "${n}" -gt 0 ] || break
[ "${pages}" -lt "${MAX_PAGES}" ] \
|| { rm -f "${out}.partial"; echo "ERROR: pagination did not end after ${MAX_PAGES} pages" >&2; return 2; }
cursor=$(next_cursor "${page}") || { rm -f "${out}.partial"; return 2; }
if [ -n "${cursor}" ]; then url="${base}&cursor=${cursor}"; else url=""; fi
done
mv "${out}.partial" "${out}" \
|| { rm -f "${out}.partial"; echo "ERROR: cannot write to SNYK_AUDIT_OUT_DIR (${OUT_DIR})" >&2; return 2; }
echo "Exported ${count} event(s) for ${scope}/${scope_id} ${FROM}..${TO} -> ${out}"
}
# Pull group-level events (role, policy, membership changes) and each org's
# events, then point the SIEM forwarder at the finished .ndjson files.
if [ -z "${SNYK_GROUP_ID:-}" ] && [ -z "${SNYK_ORG_ID:-}" ]; then
echo "ERROR: set SNYK_GROUP_ID and/or SNYK_ORG_ID - nothing to export" >&2; exit 2
fi
mkdir -p "${OUT_DIR}" \
|| { echo "ERROR: cannot create SNYK_AUDIT_OUT_DIR (${OUT_DIR})" >&2; exit 2; }
for scope in groups orgs; do
if [ "${scope}" = groups ]; then scope_id="${SNYK_GROUP_ID:-}"; else scope_id="${SNYK_ORG_ID:-}"; fi
[ -n "${scope_id}" ] || continue
[[ "${scope_id}" =~ ${UUID_RE} ]] || { echo "ERROR: the ${scope} id is not a UUID" >&2; exit 2; }
export_audit_logs "${scope}" "${scope_id}"
done
Detection Focus
Snyk documents these event classes in its audit logs; alert on them in the SIEM:
- Users invited, added, or removed, or a user’s role changed
- A service account created, modified, or deleted
- A license rule or policy modified
- A Group or Organization added or removed, or a setting changed
- Sign-in is not in these logs — alert on Snyk SSO events in the IdP instead
Appendix A: Edition Compatibility
Snyk’s plans page lists Free, Team, and Enterprise.
| Control | Free | Team | Enterprise |
|---|---|---|---|
| SAML SSO (1.1) | ❌ | ❌ | ✅ |
| Custom member roles (1.2, 3.1) | ❌ | ❌ | ✅ |
| Service Accounts (2.1) | ❌ | ❌ | ✅ |
| Snyk Broker (2.2) | ❌ | ❌ | ✅ |
| Audit Logs (4.1) | ❌ | ❌ | ✅ |
Appendix B: References
Official Snyk Documentation:
- User Docs
- What’s New (release notes)
- SSO Setup Guide
- Service Accounts
- Choose a Service Account Type
- Snyk Broker
- Universal Broker
- Classic Broker
- Vulnerability Disclosure Program
API Documentation:
Compliance Frameworks:
- ISO 27001, ISO 27017, SOC 2 Type II — via Trust Center
- Platform Compliance
Security Incidents:
- No major public incidents involving Snyk identified
Changelog
| Date | Version | Maturity | Changes | Author |
|---|---|---|---|---|
| 2026-10-02 | 0.3.1 | ai-drafted · ai-validated | Added ai-validated to this guide’s status set (now ai-drafted + ai-validated) from a validate-hth-guide run against a live Snyk Free organization: 1 surface VERIFIED-LIVE by a read-only console walk (3.2 ClickOps); no setting was changed and no Code Pack ran, because every api/ pack needs Snyk Enterprise and no token was minted. No human has reviewed this guide. Corrected against the live console: 3.2 now names the current Ignores settings (who can manage ignores, the separate Ignore issues using Snyk CLI or the .snyk file setting, the reason requirement that applies only in the Snyk app, and the Ignore approval workflow labels), replacing the claim that the admin-only setting blocks CLI ignores; 2.2 Step 1 replaces “Limit repository access” with the real Repository access control and adds Enable Workspaces; 1.1 Step 2 gains the new-navigation Group Members path and the Auth column; 3.1 Step 1 adds disabling Requesting Access | Claude Code (Opus 5.5) |
| 2026-09-25 | 0.3.0 | ai-drafted | validate-hth-guide fix loop, run offline against Snyk’s docs and REST spec (the console was signed out, so 0 surfaces were exercised live and the guide is not ai-validated): corrected SSO to Enterprise-only at Group scope with IdP-side MFA (1.1); replaced the role table and member paths (1.2); added the Enterprise gate and new-navigation paths to service accounts (2.1); rewrote Project Visibility around Organization membership and custom roles (3.1) and Ignore Policy around Organization Settings → General → Ignores, with classic and new navigation paths (3.2); refreshed Classic Broker approved-data wording (2.2); replaced 4.1’s unconfirmed Settings → Audit logs console path with API retrieval, the only route Snyk documents, and filled its Detection Focus; corrected Appendix A to Free/Team/Enterprise and removed the SCIM row (no SCIM control in this guide); repaired three redirecting Appendix B links; added read-only api Code Packs for 1.1, 1.2, 2.2, and 3.1; fixed fail-open, pagination, portability, and exit-code defects in the 2.1, 3.2, and 4.1 packs | Claude Code (Opus 5.5) |
| 2026-08-08 | 0.2.1 | ai-drafted | Added api Code Packs for §2.1 (service-account credential-type audit + legacy-key deletion via the REST service_accounts endpoints) and §4.1 (org/group audit-log export via audit_logs/search inside the 90-day window), plus a cli Code Pack for §3.2 (snyk ignore with mandatory reason and expiry, .snyk suppression audit), all verified against docs.snyk.io API and CLI references | Claude Code (Fable 5) |
| 2026-08-08 | 0.2.0 | ai-drafted | Currency pass (Tier 1 only): rewrote 2.1 for the three service-account credential types (API key never expires and is not recommended; access token 1-year max with no in-place rotation; OAuth 2.0 recommended); added Universal vs Classic Broker and the April 2026 Broker high-availability default to 2.2; documented Enterprise-only audit logs, 90-day rolling retention, and the login/logout exclusion in 4.1; repaired rotted docs.snyk.io links to the platform-administration tree and removed Trust Center / marketing pages from Appendix B. Tier 3/4 research sweep out of scope this pass. | Claude Code (Opus 4.8) |
| 2025-12-14 | 0.1.0 | ai-drafted | Initial Snyk hardening guide | Claude Code (Opus 4.5) |
Contributing
Found an issue or want to improve this guide?
- Report outdated information: Open an issue with tag
content-outdated - Propose new controls: Open an issue with tag
new-control - Submit improvements: See Contributing Guide