diff --git a/CLAUDE.md b/CLAUDE.md index 9c3e4a5..3d5e238 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,13 +107,13 @@ The report at `reports/snowflake-platform-assessment/` is a set of linked static → [tools/lateral-movement/sccm-abuse/README.md](tools/lateral-movement/sccm-abuse/README.md) — SCCM ELEVATE1/2 → [tools/lateral-movement/azure-arc/README.md](tools/lateral-movement/azure-arc/README.md) — Azure Arc MSI pivot → [tools/lateral-movement/exchange-hybrid/README.md](tools/lateral-movement/exchange-hybrid/README.md) — evoSTS token forge -→ [tools/lateral-movement/snowflake-pivot/README.md](tools/lateral-movement/snowflake-pivot/README.md) — Snowflake Chain E storage-integration enum, Chain G share / replication exfil, bind-param evasion +→ [tools/lateral-movement/snowflake-pivot/README.md](tools/lateral-movement/snowflake-pivot/README.md) — Snowflake Chain E storage-integration enum, Chain G share / replication exfil, Chain H SPCS egress depth × EAI rule matrix probe, bind-param evasion → [tools/kerberos/README.md](tools/kerberos/README.md) — S4U2self/proxy, RBCD, NTLM relay, EPA recon, NTLM reflection LPE, AES roasting ### AD CS & Identity → [tools/ad-cs/README.md](tools/ad-cs/README.md) — ESC1–ESC16, chain.py, Shadow Credentials 2026 → [tools/cloud-identity/README.md](tools/cloud-identity/README.md) — WIF, OIDC, Golden SAML, Silver SAML, SyncJacking, EvilTokens, FOCI, PRT devtools, CloudTrail blinding -→ [tools/cloud-identity/snowflake/README.md](tools/cloud-identity/snowflake/README.md) — Snowflake JWT key-pair (Chain F), PAT scope walk, SCIM token harvester +→ [tools/cloud-identity/snowflake/README.md](tools/cloud-identity/snowflake/README.md) — Snowflake JWT key-pair (Chain F), PAT scope walk + PAT discovery, SCIM token harvester, partner-integration audit (Chain J) → [tools/entra-abuse/README.md](tools/entra-abuse/README.md) — device-code, PRT, token replay (historical) ### Lateral Movement @@ -148,7 +148,7 @@ The report at `reports/snowflake-platform-assessment/` is a set of linked static → [tools/kernel-lpe/README.md](tools/kernel-lpe/README.md) — AFD.sys, CLFS, I/O Ring primitives (requires EXPLOIT_LAB_KERNEL=1) ### Supply Chain -→ [tools/supply-chain/README.md](tools/supply-chain/README.md) — Shai-Hulud npm worm, LiteLLM PyPI .pth, GitHub Actions OIDC (UNC6426), tj-actions-class +→ [tools/supply-chain/README.md](tools/supply-chain/README.md) — Shai-Hulud npm worm, LiteLLM PyPI .pth, GitHub Actions OIDC (UNC6426), tj-actions-class, Snowflake Native App version-bump (Chain C empirical) ### Phishing & Initial Access → [tools/phishing/README.md](tools/phishing/README.md) — AiTM kits (Tycoon2FA/Sneaky2FA/Rockstar2FA), ClickFix/FileFix/ConsentFix, passkey bench, vishing tabletop diff --git a/detection/snowflake/README.md b/detection/snowflake/README.md index 720d870..f35b018 100644 --- a/detection/snowflake/README.md +++ b/detection/snowflake/README.md @@ -10,17 +10,25 @@ useful when building a SIEM rule set rather than evaluating one tool. ## Per-chain mapping -| Chain | What it does | Detection rules | -|-------|--------------|-----------------| -| A — Credential theft to bulk exfil | UNC5537 replay; bulk `COPY INTO @stage` from a non-MFA / no-network-policy user. | [`bulk_exfil_baseline.yml`](sigma/bulk_exfil_baseline.yml) (new) + bind-param coverage: [`snowflake_bind_param_audit_gap.yml`](../../tools/lateral-movement/snowflake-pivot/detection/sigma/snowflake_bind_param_audit_gap.yml) | -| B — Cortex Code indirect injection | Pre-1.0.25 Cortex Code CLI executes shell-pipe-sh under indirect prompt injection. | [`cortex_code_pre_1_0_25.yml`](sigma/cortex_code_pre_1_0_25.yml) (new) | -| C — Native App Marketplace supply-chain | Installed Native App auto-updates to a manifest with new external integrations. | [`native_app_unexpected_version_bump.yml`](sigma/native_app_unexpected_version_bump.yml) (new) | -| D — Federated-IdP compromise | Forged SAML/OAuth assertion authenticates a high-privileged Snowflake user. | [`federated_login_anomaly.yml`](sigma/federated_login_anomaly.yml) (new) + [`snowflake_keypair_auth_abuse.yml`](../../tools/cloud-identity/snowflake/detection/sigma/snowflake_keypair_auth_abuse.yml) | -| E — Storage Integration cross-cloud pivot | New external stage on an integration outside the bucket allowlist. | [`snowflake_storage_integration_misuse.yml`](../../tools/lateral-movement/snowflake-pivot/detection/sigma/snowflake_storage_integration_misuse.yml) | -| F — Key-pair JWT auth abuse | Stolen RSA private key signs JWT for a service user (post-MFA reality). | [`snowflake_keypair_auth_abuse.yml`](../../tools/cloud-identity/snowflake/detection/sigma/snowflake_keypair_auth_abuse.yml) | -| G — Direct Share / Replication exfil | `ALTER SHARE ADD ACCOUNTS` or replication group with a non-allowlisted target. | [`snowflake_share_creation_unknown_consumer.yml`](../../tools/lateral-movement/snowflake-pivot/detection/sigma/snowflake_share_creation_unknown_consumer.yml) + [`snowflake_replication_group_unknown_target.yml`](../../tools/lateral-movement/snowflake-pivot/detection/sigma/snowflake_replication_group_unknown_target.yml) | -| H — SPCS over-broad EAI egress | Wildcard / OPEN_ANY network rule referenced by an `EXTERNAL ACCESS INTEGRATION`. | Covered by [`snowflake_storage_integration_misuse.yml`](../../tools/lateral-movement/snowflake-pivot/detection/sigma/snowflake_storage_integration_misuse.yml) (classifies EAI rules as critical-impact); pair with cloud-network egress observation per the chain notes. | -| I — Cortex Agent MCP poisoning | Tool output triggers planner-initiated follow-up tool calls or SQL execution. | [`cortex_agent_directive_followup.yml`](../../tools/llm-attacks/cortex/detection/sigma/cortex_agent_directive_followup.yml) + [`cortex_agent_sql_from_tool_output.yml`](../../tools/llm-attacks/cortex/detection/sigma/cortex_agent_sql_from_tool_output.yml) + [`cortex_search_rank_anomaly.yml`](../../tools/llm-attacks/cortex/detection/sigma/cortex_search_rank_anomaly.yml) | +Every chain has both an ACCOUNT_USAGE-shaped rule (for the audit-table +projection a SOC ingests on a poll) and a Snowflake Trail-shaped rule +(for the real-time event stream where Trail ingestion is enabled). The +two surfaces share the same gaps documented in the analysis companion; +the latency profile is the difference. Pick the rule that matches the +ingestion surface available on the customer's side. + +| Chain | What it does | ACCOUNT_USAGE Sigma | Trail Sigma | +|-------|--------------|---------------------|-------------| +| A — Credential theft to bulk exfil | UNC5537 replay; bulk `COPY INTO @stage` from a non-MFA / no-network-policy user. | [`bulk_exfil_baseline.yml`](sigma/bulk_exfil_baseline.yml) + bind-param coverage: [`snowflake_bind_param_audit_gap.yml`](../../tools/lateral-movement/snowflake-pivot/detection/sigma/snowflake_bind_param_audit_gap.yml) | — (folded into bulk_exfil_baseline via the streaming-ingest pipeline) | +| B — Cortex Code indirect injection | Pre-1.0.25 Cortex Code CLI executes shell-pipe-sh under indirect prompt injection. | [`cortex_code_pre_1_0_25.yml`](sigma/cortex_code_pre_1_0_25.yml) (version-string, endpoint-side) + behavioral pair: [`cortex_code_session_to_unknown_session.yml`](sigma/cortex_code_session_to_unknown_session.yml) | covered by the behavioral pair (does not depend on Trail event names) | +| C — Native App Marketplace supply-chain | Installed Native App auto-updates to a manifest with new external integrations. | [`native_app_unexpected_version_bump.yml`](sigma/native_app_unexpected_version_bump.yml) | — (Native App lifecycle still surfaces through ACCOUNT_USAGE.APPLICATIONS) | +| D — Federated-IdP compromise | Forged SAML/OAuth assertion authenticates a high-privileged Snowflake user. | [`federated_login_anomaly.yml`](sigma/federated_login_anomaly.yml) | — (use the Chain F Trail variant; same login_history shape) | +| E — Storage Integration cross-cloud pivot | New external stage on an integration outside the bucket allowlist. | [`snowflake_storage_integration_misuse.yml`](../../tools/lateral-movement/snowflake-pivot/detection/sigma/snowflake_storage_integration_misuse.yml) | [`snowflake_storage_integration_misuse_trail.yml`](../../tools/lateral-movement/snowflake-pivot/detection/sigma/snowflake_storage_integration_misuse_trail.yml) | +| F — Key-pair JWT auth abuse | Stolen RSA private key signs JWT for a service user (post-MFA reality). | [`snowflake_keypair_auth_abuse.yml`](../../tools/cloud-identity/snowflake/detection/sigma/snowflake_keypair_auth_abuse.yml) | [`snowflake_keypair_auth_abuse_trail.yml`](../../tools/cloud-identity/snowflake/detection/sigma/snowflake_keypair_auth_abuse_trail.yml) | +| G — Direct Share / Replication exfil | `ALTER SHARE ADD ACCOUNTS` or replication group with a non-allowlisted target. | [`snowflake_share_creation_unknown_consumer.yml`](../../tools/lateral-movement/snowflake-pivot/detection/sigma/snowflake_share_creation_unknown_consumer.yml) + [`snowflake_replication_group_unknown_target.yml`](../../tools/lateral-movement/snowflake-pivot/detection/sigma/snowflake_replication_group_unknown_target.yml) | [`snowflake_share_creation_unknown_consumer_trail.yml`](../../tools/lateral-movement/snowflake-pivot/detection/sigma/snowflake_share_creation_unknown_consumer_trail.yml) + [`snowflake_replication_group_unknown_target_trail.yml`](../../tools/lateral-movement/snowflake-pivot/detection/sigma/snowflake_replication_group_unknown_target_trail.yml) | +| H — SPCS over-broad EAI egress | Wildcard / OPEN_ANY network rule referenced by an `EXTERNAL ACCESS INTEGRATION`. | [`snowflake_spcs_eai_overbroad.yml`](../../tools/lateral-movement/snowflake-pivot/detection/sigma/snowflake_spcs_eai_overbroad.yml) | [`snowflake_spcs_eai_overbroad_trail.yml`](../../tools/lateral-movement/snowflake-pivot/detection/sigma/snowflake_spcs_eai_overbroad_trail.yml) | +| I — Cortex Agent MCP poisoning | Tool output triggers planner-initiated follow-up tool calls or SQL execution. | [`cortex_agent_directive_followup.yml`](../../tools/llm-attacks/cortex/detection/sigma/cortex_agent_directive_followup.yml) + [`cortex_agent_sql_from_tool_output.yml`](../../tools/llm-attacks/cortex/detection/sigma/cortex_agent_sql_from_tool_output.yml) + [`cortex_search_rank_anomaly.yml`](../../tools/llm-attacks/cortex/detection/sigma/cortex_search_rank_anomaly.yml) | [`cortex_agent_directive_followup_trail.yml`](../../tools/llm-attacks/cortex/detection/sigma/cortex_agent_directive_followup_trail.yml) | +| J — Partner-integration credential replay | Third-party SaaS holding Snowflake credentials is compromised; credential replayed from attacker infrastructure. | [`partner_integration_credential_replay.yml`](../../tools/cloud-identity/snowflake/detection/sigma/partner_integration_credential_replay.yml) | [`partner_integration_credential_replay_trail.yml`](../../tools/cloud-identity/snowflake/detection/sigma/partner_integration_credential_replay_trail.yml) | ## PAT, SCIM, and Connector secret-leak detections diff --git a/detection/snowflake/sigma/cortex_code_session_to_unknown_session.yml b/detection/snowflake/sigma/cortex_code_session_to_unknown_session.yml new file mode 100644 index 0000000..2947432 --- /dev/null +++ b/detection/snowflake/sigma/cortex_code_session_to_unknown_session.yml @@ -0,0 +1,58 @@ +title: Snowflake — Cortex Code Session Followed By Snowflake Login From New Source +id: 4e6f8091-2a3b-4c5d-9e7f-1a2b3c4d5e6f +status: experimental +description: | + Behavioral pair to `cortex_code_pre_1_0_25.yml`. Fires when a Cortex + Code session on a developer endpoint is followed within a short + correlation window by a Snowflake login for the same user from an IP + that does not match the developer host's known egress range. + + Catches the post-fix variant of Chain B: even with Cortex Code 1.0.25+, + if any future agentic surface mishandles indirect prompt injection in + the same shape, the operational signal is the same — cached + Snowflake tokens flow off the developer host, and a new Snowflake + session appears from a non-historic IP shortly after. + + Unlike `cortex_code_pre_1_0_25.yml`, this rule does not decay with the + version string. It costs more correlation state — pair an endpoint + Cortex Code session window with the Snowflake LOGIN_HISTORY join. +references: + - https://nvd.nist.gov/vuln/detail/CVE-2026-6442 + - https://www.promptarmor.com/resources/snowflake-ai-escapes-sandbox-and-executes-malware + - https://docs.snowflake.com/en/sql-reference/account-usage/login_history +author: security-research +date: 2026-05-15 +tags: + - attack.credential_access + - attack.t1528 + - attack.lateral_movement + - attack.t1550 +logsource: + product: snowflake + service: login_history +detection: + recent_cortex_code_session: + has_cortex_code_session_within_window: true + cortex_code_session_host_id|exists: true + snowflake_login_for_same_user: + is_success: true + source_ip_not_matching_host_egress: + is_login_source_in_host_egress_range: false + condition: recent_cortex_code_session and snowflake_login_for_same_user + and source_ip_not_matching_host_egress +fields: + - event_timestamp + - user_name + - client_ip + - cortex_code_session_host_id + - cortex_code_session_started_at + - cortex_code_cli_version + - authentication_method +falsepositives: + - Developer authenticates from a personal device that is not on the + corporate egress range. Maintain a per-user device-egress allowlist + so the rule is not noisy for legitimate WFH patterns. + - VPN failover that swaps the host's egress IP. Tie the host-egress + enrichment to the corporate VPN policy rather than the host's + cached IP. +level: high diff --git a/docs/analysis/databricks-vs-snowflake-platform-comparison.md b/docs/analysis/databricks-vs-snowflake-platform-comparison.md index 131f825..3ef1763 100644 --- a/docs/analysis/databricks-vs-snowflake-platform-comparison.md +++ b/docs/analysis/databricks-vs-snowflake-platform-comparison.md @@ -67,7 +67,7 @@ Both platforms expose, under different names: ## Chain-By-Chain Mapping -Where the Snowflake report uses Chain A through Chain I to organize +Where the Snowflake report uses Chain A through Chain J to organize findings, the rough Databricks analogues are: | Snowflake chain | Databricks analogue | Shared root cause | @@ -79,8 +79,9 @@ findings, the rough Databricks analogues are: | **E — Storage Integration cross-cloud pivot** | UC external location reused for a non-intended bucket; Databricks Connect IAM role reuse | The platform-side allowlist is permissive; one role serves many integrations. | | **F — Key-pair JWT auth abuse (post-MFA reality)** | Stolen PAT or SP OAuth credential on a CI host | Snowflake's RSA-key path is the post-2025 analogue of Databricks' always-existed PAT surface. The control-gap question is identical: is there a network policy on this machine identity? | | **G — Direct Share / Replication exfil** | Delta Sharing recipient pull from a third-party tenant | The provider's source-side `QUERY_HISTORY` shows no `SELECT`/`COPY` for the consumer's reads on either platform — the data motion lives in the consumer's logs, where the provider has no visibility. | -| **H — SPCS over-broad EXTERNAL ACCESS INTEGRATION** | Databricks App with permissive outbound + Volumes egress | In-tenant code runtime with attacker-pickable egress destinations; the network-inspection depth (DNS-only vs. SNI vs. L7) is the open empirical question on both platforms. | +| **H — SPCS over-broad EXTERNAL ACCESS INTEGRATION** | Databricks App with permissive outbound + Volumes egress | In-tenant code runtime with attacker-pickable egress destinations. The Snowflake assessment now ships a modeled inspection-depth × EAI-rule-shape matrix (DNS-only / SNI / L7 × wildcard / scoped / deny-by-default); the same matrix shape applies to Databricks Apps egress with the workspace's network-inspection control as the analogous knob. | | **I — MCP tool poisoning against Cortex Agents** | Genie tool result poisoning; Model Serving tool-call chain | Planner-initiated follow-up tool calls triggered by attacker-controlled tool output; the trust boundary between tool output and planner state is the same on both. | +| **J — Partner-integration credential replay (third-party-holds-our-token)** | Partner Connect integration credential held by a partner SaaS; replayed from attacker infrastructure after the partner is compromised | Long-lived machine credential held *outside* the customer's perimeter. The control gap is the customer-side network policy on the partner-integration identity — partner egress range allowlist on Snowflake; workspace IP access list on Databricks. The 2024 UNC5537 and 2026 analytics-SaaS incidents are two instances of the same primitive at different scales (developer endpoint → SaaS vendor). | --- diff --git a/docs/analysis/snowflake-platform-attack-surface-2026.md b/docs/analysis/snowflake-platform-attack-surface-2026.md index 53d0c66..4b37037 100644 --- a/docs/analysis/snowflake-platform-attack-surface-2026.md +++ b/docs/analysis/snowflake-platform-attack-surface-2026.md @@ -185,21 +185,14 @@ SBOM-aware pipelines: ### Ecosystem Context — Third-Party SaaS Token Theft -Not a Snowflake CVE, recorded here because the attack surface picture -is incomplete without it: a public incident in April 2026 saw the -ShinyHunters cluster steal Snowflake-access tokens held by a third-party -analytics-SaaS provider (Anodot) and use them to enumerate the -provider's customer data warehouses. Snowflake's stance is that no -platform-level bug was exploited; the affected tokens were valid -credentials issued to a partner integration. The chain illustrates the -same pattern UNC5537 exploited in 2024 — Snowflake credentials held by -third parties (CI runners, BI tools, analytics SaaS) live outside the +Snowflake credentials held by third parties — CI runners, BI tools, +analytics SaaS, partner data-integration providers — live outside the customer's network policy and MFA controls and are reachable through -the partner's own compromise. The detection implication is the same as -Chain A: enforce a network policy with an allowlist of egress IPs for -every service user, including those used by partner integrations, so a -stolen token from a third-party tenant cannot be replayed from an -attacker-controlled host. +the partner's own compromise. The 2026 ShinyHunters-vs-analytics-SaaS +incident proved this is a recurring class, not a one-off — the pattern +UNC5537 exploited in 2024 against developer endpoints now plays out +against B2B SaaS holding production tokens. This class is documented +as **Chain J** below. ### What the CVEs Tell Us About the Connector Stack @@ -680,18 +673,40 @@ any container running in the compute pool that the EAI scopes to. 3. Egress arbitrary data to any internet destination the wildcard rule permits. -**Open empirical question** (modeled, not confirmed by this -assessment): the SPCS network-isolation layer is documented to inspect -egress, but the depth of that inspection — DNS-only, SNI, or full -L7 — is not publicly characterized. A scoped tenant test paired with -controlled outbound from a lab SPCS service is the way to answer -this; the lab-validation SQL in the snowflake-pivot tool directory -includes the EAI-setup half of that experiment. +**Egress inspection depth — modeled matrix.** The SPCS network-isolation +layer is documented to inspect egress, but the depth of that inspection +is not publicly characterized. The +[`spcs_egress_probe.py`](../../tools/lateral-movement/snowflake-pivot/spcs_egress_probe.py) +tool walks the full inspection-depth × EAI-rule-shape × destination +matrix against the lab mock. The structural findings: + +| Inspection depth | `WILDCARD` / `OPEN_ANY` rule | `SCOPED` (allowlisted SNI) rule | `DENY_BY_DEFAULT` | +|------------------|------------------------------|---------------------------------|-------------------| +| DNS-only | egress to any destination permitted | scope is structurally unenforceable — hosts behind a shared A record bypass | egress denied | +| SNI | egress to any destination permitted | scope enforced at the SNI layer; bypass requires host-allowlist drift | egress denied | +| L7 | egress to any destination permitted | scope enforced at host+path; bypass requires breaking the L7 inspector itself | egress denied | + +Headline finding: at DNS-only inspection, a `SCOPED` EAI rule is +structurally permissive. At SNI or L7, scoping works as intended. A +wildcard rule is a sanctioned exfil channel at every depth. The matrix +is *modeled* — the inspection-depth knob is not customer-tunable and +the per-depth enforcement is a best-effort reading of vendor docs. +Confirmation against a real tenant remains a follow-on for any +organization with an SPCS deployment under assessment. **Detection counterpart**: `SNOWFLAKE.ACCOUNT_USAGE.INTEGRATIONS` diff for new EAI objects; review every `ALLOWED_NETWORK_RULES` for overly-broad rules; monitor SPCS service egress at the cloud-network -layer where possible. +layer where possible. Sigma pair: +[`snowflake_spcs_eai_overbroad.yml`](../../tools/lateral-movement/snowflake-pivot/detection/sigma/snowflake_spcs_eai_overbroad.yml) +(ACCOUNT_USAGE) + +[`snowflake_spcs_eai_overbroad_trail.yml`](../../tools/lateral-movement/snowflake-pivot/detection/sigma/snowflake_spcs_eai_overbroad_trail.yml) +(Trail). + +**Tooling**: +[`tools/lateral-movement/snowflake-pivot/spcs_egress_probe.py`](../../tools/lateral-movement/snowflake-pivot/spcs_egress_probe.py) ++ lab-validation under +[`tools/lateral-movement/snowflake-pivot/lab-validation/spcs_egress_observe.sql`](../../tools/lateral-movement/snowflake-pivot/lab-validation/spcs_egress_observe.sql). ### Chain I — MCP Tool Poisoning Against Cortex Agents @@ -719,16 +734,23 @@ as agent context. mode, the agent-executed SQL appears in `QUERY_HISTORY` attributed to the agent's user. -**Cortex Guardrails empirical finding** (this iteration): a -deliberately weak first-gen regex guardrail catches roughly half of -the public IPI payload corpus -(see [`tools/llm-attacks/cortex/guardrails-harness/`](../../tools/llm-attacks/cortex/guardrails-harness/)). -The corpus is small and structurally derived, not exhaustive — treat -the number as a *floor*: regex-only guardrails are inadequate for the -class of injections that paraphrase or restructure the directive -into surrounding prose. Customer guardrail deployment posture (off / -detect-only / enforce) is the larger determinant of effective -coverage than the rule set itself. +**Cortex Guardrails framing.** The +[`tools/llm-attacks/cortex/guardrails-harness/`](../../tools/llm-attacks/cortex/guardrails-harness/) +runs the public IPI corpus through two baseline tiers and prints the +delta: tier-1 (first-gen regex) and tier-2 (semantic-shape patterns — +directive-shape, role-assertion, fenceless sensitive SQL, +URL-near-credential, long-base64 / zero-width / confusable-script, +markdown-template render). The publishable result is **comparative**, +not a single pass-through number: tier-1 catches literal IPI markers +but misses any injection that paraphrases or restructures the +directive; tier-2 recovers the directive-shape and structural classes +but is still pattern-only and cannot reason about intent or context +boundaries. The headline for defenders is structural: regex-class +guardrails are inadequate for the *families* of injections that +paraphrase the directive (memory-injection, multimodal, context- +boundary, encoded-payload), and customer deployment posture (off / +detect-only / enforce) plus the choice of detection tier together +determine effective coverage — neither in isolation. **Detection counterpart**: `CORTEX_AGENT_HISTORY` events where the agent invoked a tool whose output text contained a `CALL_TOOL:` @@ -738,6 +760,59 @@ in a prior tool's output rather than in the user prompt. See the paired Sigma/KQL/SPL rules under [`tools/llm-attacks/cortex/detection/`](../../tools/llm-attacks/cortex/detection/). +### Chain J — Partner-Integration Token Replay (Third-Party-Holds-Our-Token) + +The 2024 UNC5537 campaign turned developer endpoints into the +initial-access channel. The 2026 analytics-SaaS-token incident +extended the same primitive into B2B SaaS: a partner tenant holding a +customer's Snowflake service-user credentials was compromised, and the +attacker replayed those credentials directly against the customer's +account. The platform's stance — correctly — is that no Snowflake bug +was exploited; the credentials were valid and were used as-issued. The +control gap is on the customer side: partner-integration users +typically do not have a network policy bound, because the partner's +egress IP range is either undocumented or changes faster than the +customer's policy review cadence. + +This is the post-MFA generalization of Chain A. Chain F covers +key-pair credential theft from infrastructure the customer owns +(CI runner, airflow worker, dev laptop). Chain J covers the same +credential class held by infrastructure the customer **does not** own +(a partner SaaS, a BI vendor, a BPO data pipeline). + +1. The partner SaaS is compromised through its own initial-access + channel — vendor-side infostealer log, OAuth phish of a partner + employee, supply-chain compromise of a partner dependency. The + compromise is *not* against the customer's perimeter. +2. The partner's credential store contains the Snowflake key-pair or + PAT issued for the customer's account. The attacker exfiltrates + it. +3. The attacker authenticates to Snowflake directly with the stolen + credential. The source IP is the attacker's infrastructure, not + the partner's documented egress range. +4. Without a network policy on the partner-integration user, the + login succeeds. The Snowflake-side `LOGIN_HISTORY` shows the + partner-integration user authenticating from a previously + unobserved IP. +5. Proceed as in Chain A from step 3 (recon + bulk exfil). The + customer's SIEM correlation against the partner's own audit will + not find a paired event because the partner was never the actor. + +**Detection counterpart**: per-user source-IP baseline on +`LOGIN_HISTORY` for every partner-integration user, joined to the +documented partner egress range. The static control is the network +policy itself — bound to every partner-integration user with an +allowlist of the partner's published egress CIDR. A partner that +cannot publish a stable egress range is itself a finding. + +**Tooling**: +[`tools/cloud-identity/snowflake/partner_integration_audit.py`](../../tools/cloud-identity/snowflake/partner_integration_audit.py) +walks the partner-integration user inventory against the documented +partner registry, flags users with no network policy bound, and +emits a remediation-prioritized report. Lab validation in +[`tools/cloud-identity/snowflake/lab-validation/partner_integration_baseline.sql`](../../tools/cloud-identity/snowflake/lab-validation/partner_integration_baseline.sql) +captures the baseline source-IP profile per partner user. + --- ## Reuse from Existing Repo Tooling @@ -874,18 +949,22 @@ What this assessment does **not** characterize, and why: are remediated server-side and rarely receive CVEs; the Snowflake Trust Center and platform security bulletins are the authoritative signal for service-side posture. -- **SPCS egress-filter depth.** SPCS network isolation is referenced; this - assessment does not characterize whether egress inspection is DNS-only, - SNI, or full L7 — service-spec misconfiguration is the modeled threat, - not bypass of the inspection itself. The lab-validation SQL under - [`tools/lateral-movement/snowflake-pivot/lab-validation/`](../../tools/lateral-movement/snowflake-pivot/lab-validation/) - includes the EAI-setup half of the experiment; the cross-account egress - half requires a tenant + a controlled cloud-network observation point. +- **SPCS egress-filter depth — tenant validation.** Chain H now ships a + modeled matrix (inspection depth × EAI rule shape × destination) and + the [`spcs_egress_probe.py`](../../tools/lateral-movement/snowflake-pivot/spcs_egress_probe.py) + PoC that drives it. The matrix is a structural reading of vendor + documentation; tenant-confirmed measurement (i.e., does the + production SPCS network layer actually behave at SNI vs. L7 depth?) + still requires a single-tenant test paired with a controlled + cloud-network observation point. - **Cortex Guardrails efficacy on production payloads.** The [Guardrails FP/FN harness](../../tools/llm-attacks/cortex/guardrails-harness/) - measures a small structurally-derived corpus against a deliberately - weak first-gen regex guardrail. Measurement against a tuned production - endpoint (with explicit opt-in) is the follow-on, not a prerequisite. + characterizes the structural delta between a regex-class baseline + and a semantic-shape baseline on a derived corpus. Measurement + against Snowflake's production endpoint (with explicit + authorization) is the follow-on; the comparison framing inside the + harness is intentional — a single percentage against either tier + would not survive contact with a tuned production deployment. --- diff --git a/infra/lab/mock-snowflake/app.py b/infra/lab/mock-snowflake/app.py index 3a10f6d..2416816 100644 --- a/infra/lab/mock-snowflake/app.py +++ b/infra/lab/mock-snowflake/app.py @@ -92,10 +92,35 @@ _known_accounts: set[str] = {LAB_ACCOUNT, "lab-attacker-acct"} _cortex_search_index: list[dict] = [] _cortex_agent_history: list[dict] = [] +_network_policies: dict[str, dict] = {} +# Native App marketplace state (Chain C). `_app_listings` is the +# provider-side catalog; `_app_installations` is each consumer account's +# installed-app state; `_app_history` is the consumer-visible audit log +# returned by /api/v2/native-apps/history. +_app_listings: dict[str, dict] = {} +_app_installations: dict[str, dict] = {} +_app_history: list[dict] = [] +# SPCS state (Chain H). `_spcs_services` is service name -> spec. +# `_spcs_egress_log` records each egress attempt with the allow/deny +# decision the inspection layer made. +_spcs_services: dict[str, dict] = {} +_spcs_egress_log: list[dict] = [] def _seed_lab_users() -> None: """Seed canonical lab service and human users.""" + _network_policies.update({ + "CORP_VPN_ONLY": { + "allowed_ip_list": ["10.50.0.0/16"], + "blocked_ip_list": [], + "comment": "Corp VPN egress range", + }, + "PARTNER_ANALYTICS_VENDOR_EGRESS": { + "allowed_ip_list": ["198.51.100.0/24"], + "blocked_ip_list": [], + "comment": "Documented egress range for Acme Analytics SaaS partner", + }, + }) _users.update({ "svc_etl": { "type": "SERVICE", @@ -103,6 +128,7 @@ def _seed_lab_users() -> None: "default_warehouse": "LAB_WH", "auth_methods": ["KEY_PAIR"], "network_policy": None, + "tags": {}, }, "svc_replication": { "type": "SERVICE", @@ -110,6 +136,7 @@ def _seed_lab_users() -> None: "default_warehouse": "LAB_WH", "auth_methods": ["KEY_PAIR"], "network_policy": None, + "tags": {}, }, "analyst_alice": { "type": "PERSON", @@ -117,6 +144,7 @@ def _seed_lab_users() -> None: "default_warehouse": "LAB_WH", "auth_methods": ["PASSWORD_MFA", "SAML"], "network_policy": "CORP_VPN_ONLY", + "tags": {}, }, "scim_provisioner": { "type": "SERVICE", @@ -124,6 +152,27 @@ def _seed_lab_users() -> None: "default_warehouse": None, "auth_methods": ["SCIM"], "network_policy": None, + "tags": {}, + }, + # Chain J — partner-integration users. The "good" one has a network + # policy bound to the documented partner egress range. The "bad" one + # is the canonical Chain J victim: a partner-issued credential with + # no network policy at all, replayable from anywhere. + "partner_acme_analytics": { + "type": "SERVICE", + "default_role": "PARTNER_READ_ROLE", + "default_warehouse": "LAB_WH", + "auth_methods": ["KEY_PAIR"], + "network_policy": "PARTNER_ANALYTICS_VENDOR_EGRESS", + "tags": {"partner_id": "acme-analytics", "owner": "data-eng"}, + }, + "partner_bi_vendor": { + "type": "SERVICE", + "default_role": "PARTNER_READ_ROLE", + "default_warehouse": "LAB_WH", + "auth_methods": ["KEY_PAIR"], + "network_policy": None, + "tags": {"partner_id": "globex-bi", "owner": "data-eng"}, }, }) @@ -859,6 +908,319 @@ def list_integrations() -> Response: return jsonify(_show_integrations()) +@app.route("/api/v2/users", methods=["GET"]) +def list_users() -> Response: + """Inventory view used by the partner-integration audit (Chain J). + + Returns the full user list with each user's tags, network-policy + binding, and the resolved policy's allowed_ip_list. Mirrors what + `SHOW USERS` + `DESC NETWORK POLICY` returns combined; in production + this requires querying SNOWFLAKE.ACCOUNT_USAGE.USERS joined to + SNOWFLAKE.ACCOUNT_USAGE.NETWORK_POLICIES. + """ + session = _require_session() + if session is None: + return jsonify({"error": "unauthorized"}), 401 + out = [] + for name, u in _users.items(): + policy_name = u.get("network_policy") + policy = _network_policies.get(policy_name) if policy_name else None + out.append({ + "name": name, + "type": u["type"], + "default_role": u["default_role"], + "auth_methods": u["auth_methods"], + "network_policy": policy_name, + "network_policy_allowed_ip_list": policy["allowed_ip_list"] if policy else None, + "tags": u.get("tags") or {}, + }) + return jsonify({"users": out}) + + +@app.route("/api/v2/network-policies", methods=["GET"]) +def list_network_policies() -> Response: + session = _require_session() + if session is None: + return jsonify({"error": "unauthorized"}), 401 + return jsonify({"policies": [ + {"name": name, **policy} for name, policy in _network_policies.items() + ]}) + + +# ── Native App Marketplace (Chain C) ───────────────────────────────────── + +def _manifest_hash(manifest: dict) -> str: + return hashlib.sha256( + json.dumps(manifest, sort_keys=True).encode()).hexdigest()[:16] + + +def _manifest_diff_added(prev: dict | None, curr: dict) -> list[str]: + """Return manifest tokens present in curr but not in prev. + + Tokens are stable, comparable strings the detection rule consumes + in its `manifest_diff_added` field. We project the manifest into a + flat token set covering: declared privileges, declared external + integrations, declared external functions, and declared container + images. + """ + def project(m: dict | None) -> set[str]: + tokens = set() + if not m: + return tokens + for priv in m.get("required_privileges", []): + tokens.add(f"PRIVILEGE:{priv}") + for eai in m.get("external_access_integrations", []): + tokens.add(f"EXTERNAL ACCESS INTEGRATION:{eai}") + for ext in m.get("external_functions", []): + tokens.add(f"EXTERNAL FUNCTION:{ext}") + for img in m.get("container_images", []): + tokens.add(f"CONTAINER:{img}") + return tokens + return sorted(project(curr) - project(prev)) + + +@app.route("/api/v2/native-apps/publish", methods=["POST"]) +def native_app_publish() -> Response: + """Provider-side publish or version-bump. + + Body: {"package": "", "version": "", "manifest": {...}} + """ + session = _require_session() + if session is None: + return jsonify({"error": "unauthorized"}), 401 + body = request.get_json(force=True) or {} + pkg = body.get("package") + ver = body.get("version") + manifest = body.get("manifest") or {} + if not pkg or not ver: + return jsonify({"error": "package and version required"}), 400 + listing = _app_listings.setdefault(pkg, { + "package": pkg, "provider_account": session["user"], + "versions": []}) + listing["versions"].append({ + "version": ver, + "manifest": manifest, + "manifest_hash": _manifest_hash(manifest), + "published_at": time.time(), + }) + return jsonify({"package": pkg, "version": ver, + "manifest_hash": _manifest_hash(manifest)}) + + +@app.route("/api/v2/native-apps/install", methods=["POST"]) +def native_app_install() -> Response: + """Consumer-side install OR auto-upgrade of a Native App. + + Body: {"package": "", "version": "", + "consumer_account": "", "auto_upgrade": bool} + Emits an APPLICATION_HISTORY entry whose shape matches what the + detection rules consume (`event_type`, `manifest_diff_added`, + `manifest_hash_previous`, `manifest_hash_current`, `auto_upgrade`). + """ + session = _require_session() + if session is None: + return jsonify({"error": "unauthorized"}), 401 + body = request.get_json(force=True) or {} + pkg = body.get("package") + ver = body.get("version") + consumer = body.get("consumer_account") or session["user"] + auto = bool(body.get("auto_upgrade")) + listing = _app_listings.get(pkg) + if not listing: + return jsonify({"error": "unknown package"}), 404 + target = next((v for v in listing["versions"] if v["version"] == ver), None) + if target is None: + return jsonify({"error": f"version {ver} not published"}), 404 + inst = _app_installations.get((consumer, pkg)) + prev_manifest = inst["manifest"] if inst else None + prev_version = inst["version"] if inst else None + prev_hash = inst["manifest_hash"] if inst else None + diff_added = _manifest_diff_added(prev_manifest, target["manifest"]) + event = "APP_INSTALLED" if prev_manifest is None else "APP_VERSION_INSTALLED" + record = { + "event_timestamp": time.time(), + "event_type": event, + "application_name": pkg, + "consumer_account": consumer, + "previous_version": prev_version, + "current_version": ver, + "manifest_hash_previous": prev_hash, + "manifest_hash_current": target["manifest_hash"], + "manifest_diff_added": diff_added, + "auto_upgrade": auto, + "actor_user": session["user"], + } + _app_history.append(record) + _app_installations[(consumer, pkg)] = { + "package": pkg, "consumer_account": consumer, + "version": ver, "manifest": target["manifest"], + "manifest_hash": target["manifest_hash"], + } + return jsonify(record) + + +@app.route("/api/v2/native-apps/applications", methods=["GET"]) +def native_app_applications() -> Response: + session = _require_session() + if session is None: + return jsonify({"error": "unauthorized"}), 401 + consumer = request.args.get("consumer_account") or session["user"] + out = [{**rec, "consumer_account": consumer} + for (acct, _pkg), rec in _app_installations.items() + if acct == consumer] + return jsonify({"applications": out}) + + +@app.route("/api/v2/native-apps/history", methods=["GET"]) +def native_app_history() -> Response: + session = _require_session() + if session is None: + return jsonify({"error": "unauthorized"}), 401 + consumer = request.args.get("consumer_account") + out = [rec for rec in _app_history + if consumer is None or rec["consumer_account"] == consumer] + return jsonify({"history": out}) + + +# ── SPCS egress simulator (Chain H) ────────────────────────────────────── +# +# Models a Snowpark Container Services egress decision under three +# configurable inspection depths and three EAI rule shapes. The result +# is the matrix the analytical doc's Chain H "open empirical question" +# refers to. The mock implements documented behaviors (the EAI rule +# language is real; the inspection-depth control is a knob the customer +# does not directly tune in production) so the matrix is a *modeled* +# best-effort, not a tenant-confirmed measurement — the chain doc says +# so explicitly. + +# Inspection depths the mock supports. +SPCS_INSPECTION_DEPTHS = {"DNS_ONLY", "SNI", "L7"} + +# A small set of fixture destinations the probe uses to characterize each +# depth × rule combo. Real SPCS egress targets are arbitrary; these +# fixtures isolate one variable per probe. +SPCS_FIXTURE_DESTINATIONS = { + "lab-loopback": {"host": "127.0.0.1", "sni": "lab.local", + "path": "/", "is_attacker": False}, + "approved-vendor": {"host": "10.50.0.10", "sni": "vendor.corp", + "path": "/api/sync", "is_attacker": False}, + "attacker-domain": {"host": "10.50.0.99", "sni": "exfil.evil", + "path": "/drop", "is_attacker": True}, +} + + +def _eai_decision(rule_shape: str, allowlist: list[str], destination: dict, + inspection_depth: str) -> tuple[bool, str]: + """Return (allowed, reason). Pure function — no shared state.""" + if rule_shape == "DENY_BY_DEFAULT": + return False, "deny-by-default rule blocks all egress" + if rule_shape == "WILDCARD": + # OPEN_ANY / wildcard: the EAI does not gate on destination. + # Inspection depth then decides. + if inspection_depth == "DNS_ONLY": + return True, "dns lookup succeeds; no further inspection" + if inspection_depth == "SNI": + return True, "wildcard rule passes any SNI" + # L7: a real inspector might examine payload, but with a + # wildcard EAI nothing in the policy expresses what to block. + return True, "wildcard rule + no L7 content rule attached" + if rule_shape == "SCOPED": + # The rule has an allowlist; the gate is per-destination. + if inspection_depth == "DNS_ONLY": + # DNS-only inspection cannot distinguish destinations + # behind the same A record; a CNAME or shared host + # bypasses the gate. + if destination["host"] in allowlist or destination["sni"] in allowlist: + return True, "host on allowlist" + return True, ("dns-only inspection cannot enforce per-host scope; " + "rule is structurally permissive at this depth") + if inspection_depth == "SNI": + if destination["sni"] in allowlist: + return True, "SNI on allowlist" + return False, f"SNI {destination['sni']} not in allowlist" + # L7 — full path-aware enforcement + if destination["sni"] in allowlist and not destination["is_attacker"]: + return True, "host+path on allowlist; L7 inspection passes" + return False, "L7 inspection denies (host off-allowlist or attacker path)" + return False, f"unknown rule_shape={rule_shape}" + + +@app.route("/api/v2/spcs/services", methods=["POST"]) +def spcs_create_service() -> Response: + session = _require_session() + if session is None: + return jsonify({"error": "unauthorized"}), 401 + body = request.get_json(force=True) or {} + name = body.get("name") + if not name: + return jsonify({"error": "name required"}), 400 + inspection = (body.get("inspection_depth") or "SNI").upper() + if inspection not in SPCS_INSPECTION_DEPTHS: + return jsonify({"error": f"inspection_depth must be one of " + f"{sorted(SPCS_INSPECTION_DEPTHS)}"}), 400 + rule_shape = (body.get("eai_rule_shape") or "SCOPED").upper() + allowlist = body.get("eai_allowlist") or [] + _spcs_services[name] = { + "name": name, + "owner": session["user"], + "inspection_depth": inspection, + "eai_rule_shape": rule_shape, + "eai_allowlist": allowlist, + "compute_pool": body.get("compute_pool", "LAB_POOL"), + "image": body.get("image", "lab/spcs-fixture:latest"), + } + return jsonify(_spcs_services[name]) + + +@app.route("/api/v2/spcs/services//egress", methods=["POST"]) +def spcs_egress_attempt(name: str) -> Response: + session = _require_session() + if session is None: + return jsonify({"error": "unauthorized"}), 401 + svc = _spcs_services.get(name) + if svc is None: + return jsonify({"error": "service not found"}), 404 + body = request.get_json(force=True) or {} + dest_key = body.get("destination") + dest = SPCS_FIXTURE_DESTINATIONS.get(dest_key) + if dest is None: + return jsonify({"error": f"destination must be one of " + f"{sorted(SPCS_FIXTURE_DESTINATIONS)}"}), 400 + allowed, reason = _eai_decision( + svc["eai_rule_shape"], svc["eai_allowlist"], dest, + svc["inspection_depth"]) + entry = { + "event_timestamp": time.time(), + "service": name, + "destination_key": dest_key, + "destination_host": dest["host"], + "destination_sni": dest["sni"], + "is_attacker_destination": dest["is_attacker"], + "inspection_depth": svc["inspection_depth"], + "eai_rule_shape": svc["eai_rule_shape"], + "decision": "ALLOW" if allowed else "DENY", + "reason": reason, + } + _spcs_egress_log.append(entry) + return jsonify(entry) + + +@app.route("/api/v2/spcs/services", methods=["GET"]) +def spcs_list_services() -> Response: + session = _require_session() + if session is None: + return jsonify({"error": "unauthorized"}), 401 + return jsonify({"services": list(_spcs_services.values())}) + + +@app.route("/api/v2/spcs/egress-log", methods=["GET"]) +def spcs_egress_log() -> Response: + session = _require_session() + if session is None: + return jsonify({"error": "unauthorized"}), 401 + return jsonify({"egress": list(_spcs_egress_log)}) + + # ── Liveness ───────────────────────────────────────────────────────────── @app.route("/health", methods=["GET"]) diff --git a/reports/snowflake-platform-assessment/attack-chains.html b/reports/snowflake-platform-assessment/attack-chains.html index c4f8cbe..4f6b55a 100644 --- a/reports/snowflake-platform-assessment/attack-chains.html +++ b/reports/snowflake-platform-assessment/attack-chains.html @@ -203,6 +203,15 @@

Attack chains

spec content for production services; inspect the compute pool role bindings and compare against documented service-to-role mappings. +
+ Egress depth — modeled finding: + the inspection-depth × EAI-rule-shape matrix (see analytical companion) shows that a + SCOPED rule is structurally permissive at DNS-only inspection — hosts behind a shared A + record bypass the gate — and enforces correctly at SNI and L7. A WILDCARD / + OPEN_ANY rule is a sanctioned exfil channel at every depth. The matrix is generated by + tools/lateral-movement/snowflake-pivot/spcs_egress_probe.py; tenant-confirmed measurement + remains a follow-on for any organization with an SPCS deployment under assessment. +
@@ -231,6 +240,37 @@

Attack chains

+ +
+
Chain J — Partner-integration credential replay (third-party-holds-our-token)
+
+

+ The post-MFA generalization of Chain A. The 2024 UNC5537 campaign exploited developer-endpoint + credentials; the 2026 analytics-SaaS incident exploited the same primitive at SaaS scale — a + partner tenant holding the customer's Snowflake service-user credentials was compromised, and the + attacker replayed those credentials from their own infrastructure. No Snowflake bug was involved; + the gap was on the customer side, where the partner-integration user typically has no network + policy bound because the partner's egress range is undocumented or volatile. +

+
    +
  1. The partner SaaS is compromised through its own initial-access path (vendor infostealer, OAuth phish of a partner employee, supply-chain compromise of a partner dependency). The customer's perimeter is not touched.
  2. +
  3. The partner's credential store contains the Snowflake key-pair or PAT issued for the customer's account. The attacker exfiltrates it.
  4. +
  5. The attacker authenticates to Snowflake directly with the stolen credential. The source IP is the attacker's infrastructure, not the partner's documented egress range.
  6. +
  7. With no network policy bound to the partner-integration user, the login succeeds. LOGIN_HISTORY shows the partner-integration user authenticating from a previously unobserved IP.
  8. +
  9. Proceed from Chain A step 3. The customer's SIEM cannot correlate against the partner's own audit because the partner was never the actor.
  10. +
+
+ Detection: per-user source-IP baseline on LOGIN_HISTORY for every + partner-integration user, joined to the documented partner egress range. The static control is the + network policy itself — bound to every partner-integration user with an allowed_ip_list + matching the partner's published egress CIDR. A partner that cannot publish a stable egress range is + itself a finding. Tooling at + tools/cloud-identity/snowflake/partner_integration_audit.py walks the inventory and + flags users with no policy bound or with a policy whose CIDRs don't cover the documented partner + egress. +
+
+