メインコンテンツへスキップ
セキュリティ事件の技術解説
セキュリティ
認証・認可
B2B SaaS
アーキテクチャ設計

連携アプリ(OAuth・サービスプリンシパル)の権限をどう設計し、棚卸しし、検知するか:ConnectWise事例から作る側・組み込む側の最小権限

盗まれた連携アプリの資格情報は、顧客側のパスワードやMFAの手前から顧客環境に入ります。MicrosoftのConnectWise事例を材料に、アプリ権限の最小化・棚卸し・自己権限付与の検知を、テスト済みのコードとともに整理します。

公開日
読了時間
23分
著者
友田 陽大
シェア

結論:連携アプリは、顧客側のパスワードとMFAの手前から入ってくる

顧客が許可したマルチテナントの連携アプリのID(資格情報)が盗まれると、攻撃者は、そのアプリが顧客環境で既に持っている権限を使って、ユーザーの対話的なサインインなしに入れます。 顧客側のパスワードの強さも、MFAも、この経路の手前にはありません。

2026年10月21日のMicrosoftの報告によれば、ConnectWiseの複数のクラウドアプリ(Cloud Backupを含む)のIDを攻撃者が掌握し、「盗まれたアプリの資格情報」で顧客環境へ認証しました。どう盗んだかは、Microsoftも述べていません。 Microsoftは、このマルチテナントアプリの侵害に、Storm-0940が責任があると評価しています。侵害後、同アクターは、ARTEXで2,250超のテナントへのサインイン試行を機械速度に加速しました。複数の環境で、Microsoft Graphを通じた偵察、アプリの資格情報の変更、権限を広げる試みも観測されています。ARTEXが侵害そのものを行ったとは、Microsoftは述べていません。また、これはMicrosoft自身のシステムや製品の侵害ではない、とも明記しています。ARTEX自体の整理はARTEXの解説にあります。

この記事は、連携アプリを作る側と、自社の環境へ組み込む側のSaaS開発責任者・エンジニアに向けています。主張は3つです。

  1. 備えの要点は、(1) アプリ単位の権限(人がサインインしていない状態で、アプリが単独で使う「アプリケーション権限」)を最小にして棚卸しする、(2) 資格情報を短命にし、保護し、できれば持たない、(3) 付与後の変化(自己権限付与、資格情報の追加)を検知する、の3つです。
  2. Conditional Access for workload identitiesは、サードパーティのマルチテナントアプリには効きません(Microsoft公式)。見落としやすい制約です。
  3. 棚卸しと検知は、入出力だけの純粋関数にすれば、テストで保証できます。この記事のコードは、合成データでテストしたもので、実テナントでは検証していません。

先に範囲を書きます。 私はWebアプリのセキュリティ監査を有料で提供していますが、Microsoft 365・Entraのテナント設定の監査は提供していません。

経営者向けの確認事項

技術の細部より先に、次の3つを確認してください。

  1. 自社の環境に入っている連携アプリと、それぞれが持つ権限を、誰が、いつ見ているか。
  2. 連携の権限を付与する手続きに、承認と記録があるか。
  3. 連携アプリが侵害されたとき、無効にする手順と責任者が決まっているか。

損害額や発生確率は、根拠のない数字になるため示しません。起こり得る影響の種類は、顧客の環境への不正なアクセス、調査・通知・監査対応の工数、顧客との契約上の問題、信頼の低下です。

確度の区分

区分意味
【確認済み】MicrosoftやIETFなどの一次資料に直接書かれている(2026年11月4日に確認)
【設計考察】確認済みの事実から、筆者が導いた分析
【未確認】公開資料からは確かめられない、または筆者が検証していない

本番の顧客データを預かる前に

Webアプリ・SaaS のセキュリティ監査 — 認可・テナント分離・設定まで、コードを読んで確かめます

用語:アプリ、サービスプリンシパル、権限、同意

用語意味(Microsoftの説明)
アプリケーションオブジェクトアプリの、全テナント共通のグローバルな表現。ひな型
サービスプリンシパルアプリの、特定のテナントでのローカルな表現。顧客のテナントごとに作られる
委任された権限(スコープ)サインインしたユーザーに代わって動くときの権限。そのユーザーが触れられる範囲に収まる
アプリケーション権限(アプリロール)サインインしたユーザーがいない、アプリ単独の動作の権限。権限に紐づくデータに広く触れられる

たとえばMicrosoft Graphのアプリケーション権限Files.Read.Allを付与されたアプリは、サインインしたユーザーがいなくても、任意のファイルを読めます(Microsoftの例)。人がログインしていないことが、ここでは利点であり、危険でもあります。 人の認証の手前の対策(パスワード、MFA、端末の確認)が効かないからです。

同意(consent)は、この権限を付与する手続きで、権限によっては管理者の同意が要ります。

何が起きたのか【確認済み】

Microsoftの報告から、設計に関わる事実だけ取り出します。

  • 経路:ConnectWiseの複数のクラウドアプリのIDが掌握され、攻撃者側のAIエージェントが、盗まれたアプリの資格情報で顧客環境に認証した。対話的なユーザーのサインインは不要だった。報告は、すでに確立された、そのアプリへの信頼を利用した、と表現する。ベンダーが共有した接続元の1つは、先のパスワードスプレー(327アカウント・8組織)の基盤と完全に一致した。
  • その後:複数の環境で、Microsoft Graphを通じた偵察、アプリの資格情報の変更、権限を広げる試みが観測された。Azure Key Vaultでの大規模なシークレットの読み取りも報告されている。
  • 規模:Microsoftは、標的化の大半は成功しなかった一方、多数の組織で短時間に特権的なクラウド永続化を得た、と述べる。一部では標的化から特権アクセスまで3時間未満だった。数百の組織が標的になったが、定量的な被害数は示していない。また、アプリの存在や認証の成功が、必ずしもデータの窃取を意味しないとも注記する。
  • 裏口:Microsoftは、攻撃者が被害環境で行ったアプリの資格情報の変更が、他のID・資格情報・クラウドサービスを通じた永続化につながり、元の侵害されたアプリが対処された後も、裏口として有効なまま残り得ると警告している。

従来の「利用者のID・パスワードの盗難」と違い、狙われたのは、アプリという別種のIDです。顧客側からは、正規に許可したアプリが、正規の権限を使っているように見えます。

作る側の設計:盗まれても広がらないようにする

自分のアプリが顧客の環境に入る権限を持つなら、そのアプリのIDが盗まれたとき、何社の何に届くかが、爆発半径です。以下の設計は、ConnectWiseの事例が示した「アプリのIDが盗まれる」事態への、一般的な備えです(Microsoftの手順そのものではありません)。この記事のコードは、顧客側の棚卸しの道具で、作る側が直接使えるのは、自社の本拠のテナントの監査ログだけです(後述)。

1. 権限を、必要最小限にする

RFC 9700(OAuth 2.0のセキュリティBCP、2025年1月)の§2.3は、アクセストークンに紐づく権限を、そのアプリや用途に必要な最小限にすべきだ(SHOULD)と述べ、同じ節で、アクセストークンの宛先を特定のリソースサーバーに限定することも推奨します。送信者拘束(sender-constrained)トークンは、トークンの再利用を防ぐ手段として§2.2.1にあります。

Microsoftの最小権限の指針は、開発時に、(1) 必要なAPI呼び出しを理解し、(2) 各呼び出しの最小権限をGraph Explorerなどで調べ、(3) 重複した権限を外し、(4) 最小のものだけを付与する、という手順です。稼働中のアプリも、過剰な権限が事故を可能にし、重くする以上、定期的な更新に値する、と書いています。

【設計考察】

判断方針
委任か、アプリ単独かサインインしたユーザーの文脈で足りるなら、委任にする。アプリ単独の権限は、人のいないバッチや同期など、必要な機能だけに限る
権限の広さ「全件の読み書き」(*.ReadWrite.All型)を避ける。後から広げるときは、顧客に再同意を求める設計にする
顧客ごとの分離顧客AのトークンでBのデータに触れられない設計にする。トークンの保管と失効を、テナント単位にする
失効顧客が連携を外したとき、トークンと資格情報を確実に失効させる。自社が侵害されたとき、全顧客分を一括で失効できる手順を作っておく

2. 盗まれる対象(資格情報)を、守り、短命にし、できれば持たない

今回の事例で盗まれたのは、アプリの資格情報です。シークレット(パスワード型の資格情報)は、漏れたら、それだけで本人になれる鍵です。

  • 保護する:資格情報は、キーボールトなど、アクセスを制御・記録できる場所に置き、開発者のPCやCIの変数に平文で置かない。
  • 短命にする:有効期限を短くし、ローテーションを自動化する。
  • 持たない:MicrosoftのワークロードIDフェデレーションは、シークレットを管理せずに、Microsoft Entraで保護されたリソースへアクセスできる仕組みで、GitHub Actions、Kubernetes、Azure外の計算基盤などのシナリオで使えます。CI/CDの鍵レス化はGitHub ActionsをOIDCで鍵レスにするで扱っています。【未確認】:顧客の環境に入るマルチテナントアプリで、どの認証方式が使えるかは、利用するAPIごとに公式文書で確かめてください。

3. 自社の本拠のテナントで、自分のアプリの変更を監視する

自分のアプリの登録(アプリケーションオブジェクト)に、意図しない資格情報が追加されていないかを、自社のテナントの監査ログで見ます。これには、後述のコードのreviewAuditEventsが使えます。顧客のテナントの中は、見えません。

組み込む側の設計:棚卸し・同意の統制・監視

棚卸し:アプリケーションIDで検索する

Microsoftの手順は、アプリケーションIDで検索し、表示名では検索しないこと、影響を受けるアプリの各テナントでのサービスプリンシパルのオブジェクトIDを記録すること、現在使っていなくても、過去に導入・許可したテナントも確認することです。

自分のテナントに付与済みの権限の確認と取り消しは、Microsoft Entraの管理センターまたはGraph・PowerShellでできます(Microsoftが示すPowerShellの取り消し手順は、少なくともCloud Application Administratorでのサインインが前提です)。

同意の統制

権限を、誰でも付与できる状態にしない。Microsoft Entraには、管理者の同意が要るアプリへのアクセスを、ユーザーが申請できるワークフロー(admin consent workflow)があり、申請を審査する担当者を指定できます。

Conditional Accessの限界【確認済み】

Conditional Access for workload identitiesは、自テナントに登録した単一テナントのサービスプリンシパルに適用できます(Workload Identities Premiumライセンスが必要)。Microsoftおよびサードパーティ製のSaaSアプリケーション(マルチテナントアプリを含む)は対象外です。マネージドIDも対象外で、アクセスレビューを代わりに使う、とMicrosoftは書いています。

ConnectWiseのようなベンダー管理のマルチテナントアプリには、「許可する接続元」などの条件付きアクセスは使えません。残る手段は、権限の最小化、同意の統制、監視です。

監視:サインインと監査ログ

Microsoft Entraのサインインログには、サービスプリンシパルのサインインのカテゴリがあります。Microsoftは、成功と失敗のサービスプリンシパルのサインインを別々に確認すること、ベンダーの想定する接続元の外からのサインインを調べること(公開された接続元だけに限らない)、そして監査ログで、アプリやサービスプリンシパルの資格情報の追加、新しいアプリ・サービスプリンシパル・所有者、アプリロールの割り当てと同意の付与、特権ロールの割り当て、Conditional Accessの変更を見ることを勧めています。

MITRE ATT&CKでは、T1098.001(追加のクラウド資格情報)とT1098.003(追加のクラウドロール)にあたります。

緊急時:無効化と保全が先、削除は後

Microsoftの手順です。

  1. 影響を受けた連携は、ベンダーの復旧案内が出るまで無効にしておく。
  2. エンタープライズアプリケーションは、最初の対応として削除しない。構成と証拠を保全してから、不可逆な操作をする。
  3. サインインと監査のログ、SIEMのデータを保全する。
  4. 無効化だけでは足りない。 攻撃者が被害環境で追加した資格情報は、元のアプリが対処された後も、裏口として残り得る。保全のあと、権限のある担当者が、確認済みの不正な資格情報・付与・所有者・割り当て・IDを除去する。

インシデント対応の全体はNIST SP 800-61 Rev.3に基づくインシデント対応にあります。

テスト済みのコード:棚卸しと権限変更の検知(合成データ)

棚卸しの判定と、監査イベントの判定を、入出力だけの純粋関数にします。Graphの呼び出しはしません。入力は、Graphのエクスポートから作る正規化済みの形で、項目名はGraphの文書に合わせています(appId、appOwnerOrganizationId、servicePrincipalType、startDateTime・endDateTime、appRoleIdとresourceId)。

前提:Node 24(型の除去で.tsを直接実行)、TypeScript strict。外部依存なし。合成データでのテストのみで、実テナントでは実行していません。

// sp-review.ts
export type Credential = { readonly kind: "secret" | "certificate"; readonly startDateTime: string; readonly endDateTime: string };
/** サービスプリンシパルに付与されたアプリケーション権限。ロールIDは、リソースとセットで初めて意味を持つ。 */
export type GrantedAppRole = { readonly resourceAppId: string; readonly appRoleId: string };
export type ServicePrincipalSnapshot = {
  readonly appId: string;
  readonly displayName: string;
  readonly servicePrincipalType: "Application" | "ManagedIdentity" | "Legacy" | "SocialIdp";
  readonly appOwnerOrganizationId: string | null;  // アプリが「登録された」テナント(アプリ以外では null)
  readonly credentials: readonly Credential[];
  readonly grantedAppRoles: readonly GrantedAppRole[];
};
export type Rule =
  | "high_privilege_app_role" | "foreign_app_only_access" | "credential_on_foreign_sp" | "unknown_app_owner"
  | "long_lived_credential" | "expired_credential" | "invalid_credential_date";
export type Severity = "high" | "medium" | "low";
export type Finding = { readonly appId: string; readonly rule: Rule; readonly severity: Severity; readonly detail: string };
export type ReviewPolicy = {
  readonly homeTenantId: string;
  readonly now: Date;
  readonly maxLifetimeDays: { readonly secret: number; readonly certificate: number }; // 自社のローテーション運用から決める数値
  readonly highPrivilegeRoles: ReadonlyMap<string, string>; // roleKey(resourceAppId, appRoleId) -> 権限名。網羅ではない
};

export const MICROSOFT_GRAPH_APP_ID = "00000003-0000-0000-c000-000000000000";
/** GUIDは大文字小文字を区別しない。一度正規化し、表記の違いで規則が黙って無効にならないようにする。 */
export const roleKey = (resourceAppId: string, appRoleId: string): string => `${resourceAppId.toLowerCase()}/${appRoleId.toLowerCase()}`;

/** Graphの文書で、強力であることを確認できる3つのアプリケーション権限。出発点であって、完全な一覧ではない。 */
export const GRAPH_HIGH_PRIVILEGE_ROLES: ReadonlyMap<string, string> = new Map([
  [roleKey(MICROSOFT_GRAPH_APP_ID, "06b708a9-e830-4db3-a914-8e69da51d44f"), "AppRoleAssignment.ReadWrite.All"],
  [roleKey(MICROSOFT_GRAPH_APP_ID, "9e3f62cf-ca93-4989-b6ce-bf83c28f9fe8"), "RoleManagement.ReadWrite.Directory"],
  [roleKey(MICROSOFT_GRAPH_APP_ID, "1bfefb4e-e0b5-418b-a88f-73c46d2cc8e9"), "Application.ReadWrite.All"],
]);

const DAY_MS = 86_400_000;
const RANK: Record<Severity, number> = { high: 0, medium: 1, low: 2 };

/** 設定の誤りは、規則を黙って無効にせず、実行を止める(NaNはどの比較でも偽になる)。 */
function assertValidPolicy(p: ReviewPolicy): void {
  const ok = (n: number) => Number.isFinite(n) && n > 0;
  if (Number.isNaN(p.now.getTime())) throw new RangeError("policy.now is not a valid date");
  if (!ok(p.maxLifetimeDays.secret) || !ok(p.maxLifetimeDays.certificate)) throw new RangeError("policy.maxLifetimeDays must be finite and > 0");
}

export function reviewServicePrincipals(inventory: readonly ServicePrincipalSnapshot[], policy: ReviewPolicy): Finding[] {
  assertValidPolicy(policy);
  const home = policy.homeTenantId.toLowerCase();
  const findings: Finding[] = [];
  for (const sp of inventory) {
    const add = (rule: Rule, severity: Severity, detail: string) => findings.push({ appId: sp.appId, rule, severity, detail });

    const powerful = sp.grantedAppRoles.flatMap((r) => policy.highPrivilegeRoles.get(roleKey(r.resourceAppId, r.appRoleId)) ?? []);
    if (powerful.length > 0) add("high_privilege_app_role", "high", `holds ${powerful.join(", ")}`);

    // 登録テナントは、アプリ由来のプリンシパルでだけ意味を持つ(マネージドIDには登録テナントがない)。
    if (sp.servicePrincipalType === "Application") {
      if (sp.appOwnerOrganizationId === null) add("unknown_app_owner", "medium", "application-backed principal without appOwnerOrganizationId");
      else if (sp.appOwnerOrganizationId.toLowerCase() !== home) {
        if (sp.grantedAppRoles.length > 0)
          add("foreign_app_only_access", "medium", `${sp.grantedAppRoles.length} application permission(s) on an app registered in another tenant`);
        // ヒューリスティック:ベンダーのアプリの資格情報は、通常ベンダー自身のテナントにある。ここに追加された資格情報は、
        // Microsoftが報告した永続化の手口(MITRE T1098.001)そのものなので、外部登録のプリンシパル上の資格情報は人の目で見る。
        if (sp.credentials.length > 0)
          add("credential_on_foreign_sp", "high", `${sp.credentials.length} credential(s) present on an app registered in another tenant`);
      }
    }

    for (const c of sp.credentials) {
      const start = Date.parse(c.startDateTime), end = Date.parse(c.endDateTime);
      if (Number.isNaN(start) || Number.isNaN(end) || end < start) { add("invalid_credential_date", "medium", `unusable ${c.kind} dates`); continue; } // 判定不能は報告する
      if (end < policy.now.getTime()) { add("expired_credential", "low", `${c.kind} expired and still present`); continue; }
      const days = (end - start) / DAY_MS, max = policy.maxLifetimeDays[c.kind];
      if (days > max) add("long_lived_credential", "medium", `${c.kind} valid for ${Math.round(days)} days (policy: ${max})`);
    }
  }
  // 実行のたびに差分が取れるよう、順序を決定的にする。
  return findings.sort((a, b) => RANK[a.severity] - RANK[b.severity] || a.appId.localeCompare(b.appId) || a.rule.localeCompare(b.rule));
}

// ---- 監査ログ側 -------------------------------------------------------------------------------------
// Entraの監査ログ(activityDisplayName / initiatedBy / targetResources)から正規化したもの。生の行を `kind` に対応づけることと、
// 両方のIDを同じ種類の識別子(appId)に揃えることは、テナント固有の作業で、ここでは行わない。
// 片方がオブジェクトID、もう片方がappIdだと、下の自己付与の比較は黙って一度も成立しない。
export type AuditEvent = {
  readonly at: string;
  readonly kind: "role_member_added" | "app_role_assigned" | "credential_added";
  readonly actorAppId: string | null;   // null = 人が実行。これらの規則では評価しない(盗まれた管理者は、ここでは見えない)
  readonly targetAppId: string;
};
export type AuditPolicy = { readonly allowedCredentialWriters: ReadonlySet<string>; readonly allowedPrivilegeGrantors: ReadonlySet<string> };
export type AuditAlert = { readonly rule: "self_privilege_grant" | "privilege_grant_by_app" | "unexpected_credential_writer"; readonly severity: Severity; readonly event: AuditEvent };

export function reviewAuditEvents(events: readonly AuditEvent[], policy: AuditPolicy): AuditAlert[] {
  const alerts: AuditAlert[] = [];
  for (const e of events) {
    const actor = e.actorAppId?.toLowerCase();
    if (actor === undefined) continue;
    if (e.kind === "credential_added") {
      if (!policy.allowedCredentialWriters.has(actor)) alerts.push({ rule: "unexpected_credential_writer", severity: "medium", event: e });
    } else if (actor === e.targetAppId.toLowerCase()) alerts.push({ rule: "self_privilege_grant", severity: "high", event: e });
    else if (!policy.allowedPrivilegeGrantors.has(actor)) alerts.push({ rule: "privilege_grant_by_app", severity: "medium", event: e });
  }
  return alerts;
}

設計上の要点です。

  • foreign_app_only_access:他のテナントで登録されたアプリが、アプリ単独の権限を持っていたら見直し対象にします。委任された権限だけのものは、このルールの対象にしません(ただし、テナント全体への広い委任の同意も、別途見直す価値があります。この関数は評価しません)。
  • credential_on_foreign_sp:外部登録のプリンシパルに資格情報があれば、人が見ます。筆者のヒューリスティックで、Microsoftが示した規則ではありません。正規の用途で付く場合もあります。
  • self_privilege_grant:アプリが自分自身にロールを付ける動きで、Microsoftの検出にある「アプリが自分に高権限のロールを割り当てた」「サービスプリンシパルが自分にアプリロールの割り当てを追加した」アラートと同じ発想です。
  • ロールIDは、リソースとセットで判定し、大文字小文字は区別しません。設定の誤り(NaNなど)は、規則を黙って無効にせず、例外にします。

テスト(11件)

// sp-review.test.ts — node --test sp-review.test.ts
import test from "node:test";
import assert from "node:assert/strict";
import { GRAPH_HIGH_PRIVILEGE_ROLES, MICROSOFT_GRAPH_APP_ID, reviewAuditEvents, reviewServicePrincipals, type AuditEvent, type ReviewPolicy, type ServicePrincipalSnapshot } from "./sp-review.ts";

const HOME = "11111111-1111-1111-1111-111111111111", VENDOR = "99999999-9999-9999-9999-999999999999";
const APP_RW = { resourceAppId: MICROSOFT_GRAPH_APP_ID, appRoleId: "1bfefb4e-e0b5-418b-a88f-73c46d2cc8e9" }; // Application.ReadWrite.All on Microsoft Graph
const policy: ReviewPolicy = { homeTenantId: HOME, now: new Date("2026-11-04T00:00:00Z"), maxLifetimeDays: { secret: 180, certificate: 730 }, highPrivilegeRoles: GRAPH_HIGH_PRIVILEGE_ROLES };
const sp = (o: Partial<ServicePrincipalSnapshot> & { appId: string }): ServicePrincipalSnapshot =>
  ({ displayName: "x", servicePrincipalType: "Application", appOwnerOrganizationId: HOME, credentials: [], grantedAppRoles: [], ...o });
const rules = (inv: ServicePrincipalSnapshot[], p = policy) => reviewServicePrincipals(inv, p).map((f) => f.rule);

test("a high-privilege role is matched on resource + role id, case-insensitively", () => {
  assert.deepEqual(rules([sp({ appId: "a", grantedAppRoles: [APP_RW] })]), ["high_privilege_app_role"]);
  assert.deepEqual(rules([sp({ appId: "a", grantedAppRoles: [{ resourceAppId: MICROSOFT_GRAPH_APP_ID.toUpperCase(), appRoleId: APP_RW.appRoleId.toUpperCase() }] })]), ["high_privilege_app_role"]);
  assert.deepEqual(rules([sp({ appId: "a", grantedAppRoles: [{ resourceAppId: "some-other-resource", appRoleId: APP_RW.appRoleId }] })]), []); // same GUID, different resource
});
test("a vendor-registered app is flagged for app-only access, not for delegated-only", () => {
  assert.deepEqual(rules([sp({ appId: "v1", appOwnerOrganizationId: VENDOR })]), []);
  assert.deepEqual(rules([sp({ appId: "v2", appOwnerOrganizationId: VENDOR.toUpperCase(), grantedAppRoles: [{ resourceAppId: "r", appRoleId: "x" }] })]), ["foreign_app_only_access"]);
  assert.deepEqual(rules([sp({ appId: "h", appOwnerOrganizationId: HOME.toUpperCase(), grantedAppRoles: [{ resourceAppId: "r", appRoleId: "x" }] })]), []); // casing must not make a home app 'foreign'
});
test("any credential on a vendor-registered principal is flagged high; the same credential on a home app is not", () => {
  const c = [{ kind: "certificate" as const, startDateTime: "2026-10-01T00:00:00Z", endDateTime: "2027-01-01T00:00:00Z" }];
  assert.deepEqual(rules([sp({ appId: "v", appOwnerOrganizationId: VENDOR, credentials: c })]), ["credential_on_foreign_sp"]);
  assert.deepEqual(rules([sp({ appId: "h", credentials: c })]), []);
});
test("managed identities are not 'foreign'; an application without an owner id is reported", () => {
  assert.deepEqual(rules([sp({ appId: "m", servicePrincipalType: "ManagedIdentity", appOwnerOrganizationId: null, grantedAppRoles: [{ resourceAppId: "r", appRoleId: "x" }] })]), []);
  assert.deepEqual(rules([sp({ appId: "n", appOwnerOrganizationId: null })]), ["unknown_app_owner"]);
  assert.deepEqual(rules([sp({ appId: "m2", servicePrincipalType: "ManagedIdentity", appOwnerOrganizationId: null, grantedAppRoles: [APP_RW] })]), ["high_privilege_app_role"]); // privilege rule still applies
});
test("secret AND certificate lifetimes are judged; expired credentials are hygiene", () => {
  const f = rules([sp({ appId: "c", credentials: [
    { kind: "secret", startDateTime: "2026-10-01T00:00:00Z", endDateTime: "2027-10-01T00:00:00Z" },       // 365d > 180d
    { kind: "secret", startDateTime: "2026-10-01T00:00:00Z", endDateTime: "2026-12-01T00:00:00Z" },       // ok
    { kind: "certificate", startDateTime: "2026-10-01T00:00:00Z", endDateTime: "2030-10-01T00:00:00Z" },  // > 730d
    { kind: "certificate", startDateTime: "2026-10-01T00:00:00Z", endDateTime: "2027-10-01T00:00:00Z" },  // 365d: over the SECRET limit, within the CERTIFICATE limit -> must NOT be flagged
    { kind: "secret", startDateTime: "2025-01-01T00:00:00Z", endDateTime: "2025-06-01T00:00:00Z" },       // expired
  ] })]);
  assert.deepEqual([...f].sort(), ["expired_credential", "long_lived_credential", "long_lived_credential"]);
});
test("unusable dates (garbage, or end before start) are reported, never silently passed", () => {
  const bad = (s: string, e: string) => rules([sp({ appId: "d", credentials: [{ kind: "secret", startDateTime: s, endDateTime: e }] })]);
  assert.deepEqual(bad("garbage", "2027-01-01T00:00:00Z"), ["invalid_credential_date"]);
  assert.deepEqual(bad("2027-01-01T00:00:00Z", "2026-12-01T00:00:00Z"), ["invalid_credential_date"]);
});
test("a broken policy throws instead of quietly disabling a rule", () => {
  assert.throws(() => rules([], { ...policy, maxLifetimeDays: { secret: Number.NaN, certificate: 730 } }), RangeError);
  assert.throws(() => rules([], { ...policy, maxLifetimeDays: { secret: 180, certificate: 0 } }), RangeError);
  assert.throws(() => rules([], { ...policy, now: new Date("nope") }), RangeError);
});
test("output order is deterministic and the input is not mutated", () => {
  const inv = [sp({ appId: "z", grantedAppRoles: [APP_RW] }), sp({ appId: "b", grantedAppRoles: [APP_RW] })];
  const before = JSON.stringify(inv);
  assert.deepEqual(reviewServicePrincipals(inv, policy).map((x) => x.appId), ["b", "z"]);
  assert.equal(JSON.stringify(inv), before);
  assert.deepEqual(reviewServicePrincipals([], policy), []);
});

const ev = (o: Partial<AuditEvent> & Pick<AuditEvent, "kind" | "targetAppId">): AuditEvent => ({ at: "2026-11-04T00:00:00Z", actorAppId: null, ...o });
const none = { allowedCredentialWriters: new Set<string>(), allowedPrivilegeGrantors: new Set<string>() };
test("an app granting a role to ITSELF is high; to ANOTHER principal is medium unless the grantor is allow-listed", () => {
  const a = reviewAuditEvents([
    ev({ kind: "role_member_added", actorAppId: "app-1", targetAppId: "APP-1" }),   // self (case-insensitive)
    ev({ kind: "app_role_assigned", actorAppId: "app-1", targetAppId: "app-2" }),   // app -> other
    ev({ kind: "app_role_assigned", actorAppId: "provisioner", targetAppId: "app-2" }),
  ], { ...none, allowedPrivilegeGrantors: new Set(["provisioner"]) });
  assert.deepEqual(a.map((x) => [x.rule, x.severity]), [["self_privilege_grant", "high"], ["privilege_grant_by_app", "medium"]]);
});
test("credential additions by an app outside the allow-list are flagged", () => {
  const a = reviewAuditEvents([
    ev({ kind: "credential_added", actorAppId: "deploy-bot", targetAppId: "app-1" }),
    ev({ kind: "credential_added", actorAppId: "rogue", targetAppId: "app-1" }),
  ], { ...none, allowedCredentialWriters: new Set(["deploy-bot"]) });
  assert.deepEqual(a.map((x) => [x.rule, x.event.actorAppId]), [["unexpected_credential_writer", "rogue"]]);
});
test("KNOWN LIMIT, pinned on purpose: events initiated by a human (actorAppId = null) are not evaluated", () => {
  assert.deepEqual(reviewAuditEvents([ev({ kind: "credential_added", targetAppId: "app-1" }), ev({ kind: "role_member_added", targetAppId: "app-1" })], none), []);
});

11件すべて通過し、ソースとテストの両方でtsc --strict --noUncheckedIndexedAccess --noEmit --allowImportingTsExtensionsの型検査も通りました。テストが失敗しうるかを、次の12種の変異で確かめました:登録テナント判定の反転、ロールのキーがリソースを無視、ロールのキーが小文字化されない、外部プリンシパルの資格情報ルールの削除、マネージドIDをアプリ扱い、終了が開始より前の日付を許す、設定のNaNを許す、証明書の上限にシークレットの上限を使う(とその逆)、自己付与の比較が大文字小文字を区別、自己付与の判定の削除、アプリから他者への付与を検知しない。最初の版では、「証明書の上限にシークレットの上限を使う」変異が検出されず、境界のテストを足して塞ぎました。 現在は、12種すべてが検出されます。

このコードの限界

  • 実テナントでは動かしていません。 Graphの項目名は公式文書で確認しましたが、実際のエクスポートからServicePrincipalSnapshotとAuditEventを作る変換(ID種別の統一を含む)は、各自の仕事で、ここでは検証していません。
  • 高権限の一覧は網羅ではありません。 3つだけです。
  • 人が実行した操作は、監査の規則で評価しません(盗まれた管理者による資格情報の追加は、見えません)。テストで、この限界を明示的に固定しています。
  • テナント全体への広い委任の同意、所有者の変更は評価しません。
  • 閾値(maxLifetimeDays)は、自社のローテーション運用から決める数値で、推奨値ではありません。
  • 検知は、事後の気づきです。 阻止ではありません。何を自動で止めるかは、別に設計が要ります。

自力で確認できること、独立したレビューが役立つこと

私はWebアプリのセキュリティ監査を有料で提供しています。この節には利益相反があるため、自力で足りることを先に書きます。

自力で始められること(無料)

  • 自社のテナントの連携アプリを、アプリケーションIDで棚卸しし、アプリケーション権限を持つものと、他のテナントで登録されたものを洗い出す。上のコードの考え方が使えます。
  • 資格情報の期限と、外部登録のアプリに付いた資格情報を確認する。
  • 権限の自己付与や、想定外の資格情報の追加を、監査ログで見る。
  • 自社が作るアプリの認可が、他テナントのデータを守れているかは、ARTEX型AI攻撃にWebアプリはどう備えるべきかの方法で、自分でテストにできます。
  • Next.js × Supabaseなら、無料のOSS(Aegis)で現状を可視化できます(/aegis)。

独立したレビューが役立つことがある場面(私の提供範囲に限ります)

  • 自社が作るアプリのAPIの認可とテナント分離を、第三者の目で確かめたいとき(マルチテナントSaaSのデータ分離と認可の設計を、コードで確かめる)。
  • 連携の資格情報の保管と失効の設計を、アプリ側のコードと設定から確かめたいとき。

提供していないこと:この記事の主題である、顧客側のMicrosoft 365・Entraのテナント設定の監査は、私の提供範囲ではありません。 侵害後のフォレンジックも同様です。棚卸しは、Microsoftの公式文書の手順と、自社の情報システム部門、または専門の事業者で進めてください。

必要とは限らない場面:連携アプリの数が少なく、権限が最小で、上の自力の確認が一巡している場合は、まず自力の運用を回すほうが合理的です。

自社アプリの認可・テナント分離・設定まで、コードを読んで確かめる監査の範囲と料金は/security-auditにあります。ログの設計と検知の全体はセキュリティログと検知エンジニアリング、OAuthのトークンの基本はIDトークンとアクセストークンの違いをご覧ください。

よくある質問

サービスプリンシパルとアプリケーションオブジェクトは何が違いますか?
Microsoftの説明では、アプリケーションオブジェクトは、全テナントで使うアプリのグローバルな表現(ひな型)で、サービスプリンシパルは、特定のテナントでのローカルな表現(実体)です。マルチテナントのアプリは、利用する顧客のテナントごとにサービスプリンシパルが作られます。顧客側で棚卸しできるのは、自分のテナントにあるサービスプリンシパルです。
委任された権限とアプリケーション権限は何が違いますか?
委任された権限は、サインインしたユーザーに代わって動く場合の権限で、そのユーザーが触れられる範囲に収まります。アプリケーション権限(アプリロール)は、サインインしたユーザーがいないアプリ単独の動作に使われ、権限に紐づくデータにアプリが広く触れられます。たとえばFiles.Read.Allは、アプリが任意のファイルを読めます。そのため、より慎重に付与し、棚卸しします。
Conditional Accessで、連携アプリの侵害は防げますか?
防げない場合があります。Microsoftの公式文書では、ワークロードID向けのConditional Accessは、自テナントに登録した単一テナントのサービスプリンシパルに適用でき(Workload Identities Premiumライセンスが必要)、Microsoftおよびサードパーティ製のSaaSアプリ(マルチテナントアプリを含む)は対象外と明記されています。ベンダーが管理するマルチテナントアプリには、権限の最小化、同意の統制、監視で備えます。
連携アプリが侵害されたと分かったら、まず何をしますか?
Microsoftの手順では、まず影響を受けた連携を無効にし、エンタープライズアプリケーションは最初の対応として削除せず、構成と証拠を保全します。アプリケーションIDで検索し(表示名では検索しない)、各テナントのサービスプリンシパルのオブジェクトIDを記録します。無効化だけでは、攻撃者が他のIDやアプリに追加した資格情報が裏口として残り得るため、保全後に、権限のある担当者が確認済みの不正な資格情報・付与・所有者・割り当てを除去します。
自社で確認できることと、外部レビューが要ることの境目は?
連携アプリの権限の棚卸し、資格情報の期限の確認、権限の自己付与の検知は、自力で始められます。外部レビューが役立つのは、自社が作るアプリのAPIの認可やテナント分離、連携の資格情報の保管と失効の設計を、第三者の目で確かめたいときです。なお、Microsoft 365やEntraのテナント設定の監査は、筆者は提供していません。

参考文献

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

本番の顧客データを預かる前に

Webアプリ・SaaS のセキュリティ監査 — 認可・テナント分離・設定まで、コードを読んで確かめます

ツールが拾える穴に加えて、「誰がどのデータに触れてよいか」という設計の穴まで、ソースコードと設定から確かめ、優先順位つきの修正方針を出します。Next.js / Supabase に限らず、Rails・Laravel・Django・Go などの Web アプリと AWS / GCP / Vercel の設定も対象です。「完全に安全」とは言いません。

プロジェクト単位(請負)・技術顧問のどちらにも対応可能です。まずは30分の無料技術相談から。

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

あわせて読みたい