v0.3.1 AI Drafted AI Validated

Snyk Hardening Guide

Security Last updated: 2026-10-02

AppSec platform security for service accounts, SCM integrations, and Broker configs

View:

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
  2. Integration Security
  3. Data Security
  4. Monitoring & Detection

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)

  1. Navigate to: Group → Settings → SSO (classic navigation) · Settings → Security and access → SSO at Group scope (new navigation)
  2. 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
  3. Choose how new users join: Require an invite rather than Open to all
  4. 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

  1. 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
  2. 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
hth-snyk-1.01-sso-connection-audit.sh View source on GitHub ↗
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

  1. Navigate to: Organization → Members (classic navigation) · Settings → Security and access → Members (new navigation)
  2. Click a member’s Role entry and select the least-privileged role the person needs
  3. Keep Group Admin and Org Admin to named owners, and review the member list on a fixed cadence
  4. Org Collaborator can ignore issues by default — restrict ignores to admins with control 3.2

Code Implementation

Code Pack: API Script
hth-snyk-1.02-membership-role-audit.sh View source on GitHub ↗
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

  1. 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)
  2. Review all service accounts and record the credential type of each
  3. Remove unused accounts
  4. Flag every API-key service account for migration to OAuth 2.0

Step 3: Credential Lifecycle

  1. Create one service account per CI/CD pipeline or integration — never share credentials across consumers
  2. Assign the least-privileged role the integration needs
  3. For access tokens, diary the expiry date and schedule the replacement service account before it lapses (there is no in-place renewal)
  4. Store credentials in a secrets manager; never commit them or echo them in pipeline logs

Code Implementation

Code Pack: API Script
hth-snyk-2.01-service-account-token-audit.sh View source on GitHub ↗
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.json replaces 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

  1. Navigate to: Settings → Integrations (classic and new navigation; Broker connections are under Settings → Integrations → Snyk Broker in the new navigation)
  2. Open each SCM integration (for example Settings → Integrations → GitHub) and confirm it is still needed
  3. 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)
  4. 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

  1. Deploy the Broker inside your network so SCM credentials never leave your perimeter
  2. Restrict the permitted request set — in Classic Broker, the default approved-data list tuned with ACCEPT rule flags, or a custom accept.json that replaces it (and disables those flags); in Universal Broker, per-connection configuration
  3. Limit exposed endpoints to the minimum the Snyk integration requires
  4. Verify high-availability replica count matches your availability requirement

Code Implementation

Code Pack: API Script
hth-snyk-2.02-broker-connection-inventory.sh View source on GitHub ↗
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

  1. 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
  2. Navigate to: Organization → Members (classic navigation) · Settings → Security and access → Members (new navigation), and remove anyone who no longer needs access
  3. 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)

  1. Navigate to: Group → Settings → Member roles (classic navigation) · Settings → Security and access → Member roles at Group scope (new navigation)
  2. 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

  1. 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
  2. Review who holds report permissions in every access review (control 1.2)

Code Implementation

Code Pack: API Script
hth-snyk-3.01-org-visibility-audit.sh View source on GitHub ↗
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

  1. Navigate to: Organization → Settings → General → Ignores (classic navigation) · Organization scope Settings → Organization settings → General → Ignores (new navigation)
  2. 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
  3. Under Ignore issues using Snyk CLI or the .snyk file, select Snyk app and API only so .snyk file ignores in a repository are not applied. Ignores managed in Snyk, including those created through the CLI, still apply
  4. 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)

  1. 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
  2. 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
  3. 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

  1. Always set an expiry. CLI ignores default to 30 days, and a .snyk expires: value that is not in YYYY-MM-DDThh:mm:ss.fffZ form makes the ignore persist indefinitely
  2. Review ignored issues on a fixed cadence

Code Implementation

Code Pack: CLI Script
hth-snyk-3.02-governed-ignore.sh View source on GitHub ↗
# 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

  1. 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
  2. Review user activities against the Detection Focus list below

Step 2: Forward to SIEM Before the 90-Day Window Closes

  1. Pull group- and org-level audit events via the audit logs API
  2. Schedule collection at an interval well inside the 90-day retention window so no events age out uncollected
  3. 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

  1. Forward Snyk SSO sign-in and sign-out events from your identity provider (the Snyk audit endpoints do not carry them)
  2. Correlate IdP authentication events with Snyk audit events to reconstruct a complete session-to-action trail

Code Implementation

Code Pack: API Script
hth-snyk-4.01-audit-log-export.sh View source on GitHub ↗
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:

API Documentation:

Compliance Frameworks:

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?