Skip to content

[Tier 5] SaaS-to-SaaS OAuth chain mapping + chain detection rules #58

@dcoln25-writer

Description

@dcoln25-writer

Problem

Aperio's shadow-IT page (/shadow-it) lists every OAuth app a user granted — flat list. It treats each OauthAppGrant row as terminal. But real SaaS-to-SaaS abuse compounds through chains:

  • A user installs Slack App Alpha. Alpha holds team:write scope. Alpha is itself an OAuth client that grants tokens to a downstream app Beta, which exfils.
  • A Salesforce Connected App X is installed; X's refresh_token is in turn used by an external integration that talks to GitHub on the customer's behalf.
  • A GitHub App installs another GitHub App via the org-management API (yes, this is possible) — token holds permissions the original installer never approved.
  • M365 service principal A holds AppRoleAssignment.ReadWrite.All; A grants itself or another SP an admin scope without a human in the loop.

These chains are the next-frontier SaaS attack class ("SaaSjacking" in some vendor materials, "OAuth supply chain" in others). Commercial SSPMs barely surface them; an OSS engine that maps and alerts on them is genuinely novel.

Goals

  1. OAuth client graph: model OAuth clients as first-class nodes that connect to each other (publisher/installer/delegated-grantee relationships).
  2. Chain discovery: identify chains where an OAuth grant transitively confers access to another OAuth grant.
  3. Chain risk scoring: a chain's risk = function of every node's scope sensitivity (CRITICAL/HIGH/MEDIUM/LOW set from existing graduated risk) and the publisher trust of every link.
  4. Detection rules for the dangerous chain shapes (e.g. "low-trust publisher gates access to a CRITICAL-scope downstream grant").
  5. UI: a "chain explorer" that shows the full transitive grant graph for any starting app or user.

Non-goals

  • Not modeling permissions that aren't OAuth-derived (file-level ACLs are out of scope here; that's Add Go RPC security asset reads #15).
  • Not detecting runtime token-theft (no traffic-mirror feature) — chain analysis is structural, not behavioral.

Proposed design

What's already there

  • OauthAppGrant model with provider, externalAppId, appDisplayName, scopes, userEmail, assetId. The per-user, per-app axis is complete.
  • SecurityAsset type OAUTH_APP with riskScore, containsSensitiveData, isPrivileged, exposureLevel already exposed in the security graph.
  • Graduated scope risk classification (CRITICAL/HIGH/MEDIUM/LOW sets) lives in workers/ingestion-worker.ts googleOauthGrantRisk and similar — extend rather than rebuild.

New domain model

enum OauthClientPublisher {
  FIRST_PARTY        // ships with the provider (e.g. Google's own clients)
  VERIFIED_VENDOR    // platform-verified marketplace app
  UNVERIFIED_VENDOR  // marketplace-listed but unverified
  TENANT_INTERNAL    // built by the customer themselves
  UNKNOWN
}

model OauthClient {
  id                String   @id @default(cuid())
  organizationId    String   @map("organization_id")
  provider          SaaSProvider
  externalClientId  String   @map("external_client_id") @db.VarChar(255)
  displayName       String?  @db.VarChar(255)
  publisherKind     OauthClientPublisher @default(UNKNOWN) @map("publisher_kind")
  publisherDomain   String?  @map("publisher_domain") @db.VarChar(255)
  trustScore        Int      @default(50) @map("trust_score")   // 0-100; threat-intel input (#51) feeds in
  isOauthBlocklisted Boolean @default(false) @map("is_oauth_blocklisted")
  observedAt        DateTime @default(now()) @map("observed_at")
  organization      Organization @relation(...)
  @@unique([organizationId, provider, externalClientId])
  @@index([organizationId, provider, publisherKind])
  @@map("oauth_clients")
}

enum OauthGrantEdgeKind {
  INSTALLED_BY            // a Person installed this client
  DELEGATED_BY            // this client acts on behalf of another client
  PUBLISHED_BY            // this client's publisher is the same legal entity as another
  IMPERSONATES            // domain-wide-delegation style
  EXTENDS_VIA_FRAMEWORK   // e.g. Slack manifest depends on another app
}

model OauthGrantEdge {
  id              String   @id @default(cuid())
  organizationId  String   @map("organization_id")
  fromClientId    String   @map("from_client_id")
  toClientId      String   @map("to_client_id")
  kind            OauthGrantEdgeKind
  evidence        Json
  observedAt      DateTime @default(now()) @map("observed_at")
  organization    Organization @relation(...)
  @@unique([organizationId, fromClientId, toClientId, kind])
  @@index([organizationId, toClientId])
  @@map("oauth_grant_edges")
}

Chain discovery

A new internal/oauthchains/ Go package:

  1. On every shadow-IT scan, materialize OauthClient rows from OauthAppGrant.
  2. Pull provider-specific relationship signals:
    • Google: clientId lineage from OAuth consent records; service-account → DWD mapping (already partially modeled).
    • M365: oauth2PermissionGrants and appRoleAssignments between service principals.
    • Slack: app manifest dependencies; "added by another app" install events.
    • Salesforce / GitHub Apps: API-driven app-to-app installs.
  3. Persist edges as OauthGrantEdge.
  4. Compute connected components per org; cache chain summaries (length, max risk, min trust, all scope union).

Chain rules (built into #47 once it lands)

Rule Trigger
oauth.unverified_chain_to_critical_scope Any chain where any node is UNVERIFIED_VENDOR and any node holds a CRITICAL scope.
oauth.first_party_proxied_to_third_party First-party client (e.g. Google service account) used to proxy access to a third-party app.
oauth.blocklisted_app_in_chain Any node in the chain is on the OAuth block list (#51).
oauth.cross_provider_chain_to_data_resource Chain spans 2+ providers and terminates in a DATA_RESOURCE asset.
oauth.transitive_admin_scope Terminal client holds admin scope; install path went through a non-admin user.

UI surface

  • /shadow-it/chains — list of discovered chains, sortable by length, max risk, min trust.
  • Chain detail page — force-directed graph of OauthClient nodes, edges colored by kind, terminal nodes' scopes/risk surfaced, "find affected users" drill-down.
  • Per-app chain view — on any OauthAppGrant detail page, show its transitive reach.

Phasing

Phase Scope
P1 OauthClient model + materialization from existing OauthAppGrant rows; publisher-kind classification (heuristic from clientId domain); chain summary table; /shadow-it/chains list page
P2 M365 SP-to-SP edge discovery; Slack app manifest dependencies; chain detection rules (#47 dependent)
P3 GitHub Apps + Salesforce Connected App chain discovery; graph viz; threat-intel-driven trust score (#51 dependent)
P4 Auto-remediation proposal templates ("revoke this entire chain's tokens for User X") through the existing AgentProposal gate

Open questions

  • Provider APIs vary wildly in what they expose about app-to-app relationships — some chains can only be partially reconstructed.
  • Trust score should be a multi-source blend: marketplace verified flag + publisher domain age + threat-intel verdict + community signal from Threat-intel enrichment: VT, GreyNoise, AbuseIPDB, CISA KEV, OAuth blocklist #51's blocklist repo.
  • Should a chain that crosses tenants (Aperio sees the same OAuth clientId across multiple of its customers) inform the trust score? (Probably yes, anonymized.)

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    detection-engineeringDetection rules, rule packs, backtestingoauth-chainSaaS-to-SaaS OAuth chain analysistier-5-surface-expansionTier 5: expand the surfaces Aperio covers

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions