LESSON 30分

ストーリー

佐藤CTO
脆弱性は書かれた瞬間に防ぐのが最もコスト効率が良い

佐藤CTOがプルリクエストのレビュー画面を開きました。

佐藤CTO
脆弱性診断で見つけてから直すのは10倍のコストがかかる。本番で発見されれば100倍だ。だからこそ、コードレビューの段階でセキュリティの問題を潰す仕組みが必要なんだ
あなた
具体的にはどんな仕組みでしょうか?
佐藤CTO
チェックリスト、静的解析ツールの統合、そしてレビュワーのセキュリティ知識の底上げ — この3本柱だ

セキュリティコードレビューチェックリスト

観点別チェックリスト

カテゴリチェック項目重要度
入力バリデーション全ての外部入力にバリデーションが適用されているかCritical
入力バリデーションホワイトリスト方式で許可値を定義しているかHigh
入力バリデーションファイルアップロードの種類・サイズ制限があるかHigh
認証・認可全エンドポイントに認証チェックがあるかCritical
認証・認可オブジェクトレベルの認可チェック(BOLA対策)があるかCritical
認証・認可ロールベースアクセス制御が正しく実装されているかHigh
データ保護機密データ(PII、クレジットカード等)がログに出力されていないかCritical
データ保護パスワードが適切にハッシュ化されているか(bcrypt/Argon2)Critical
データ保護暗号化キーがソースコードにハードコードされていないかCritical
エラーハンドリングエラーレスポンスに内部情報(スタックトレース等)が含まれていないかHigh
エラーハンドリング例外が適切にキャッチされ、安全なデフォルト値に置換されているかMedium
SQL/NoSQLパラメータ化クエリまたはORMを使用しているかCritical
SQL/NoSQL動的なクエリ構築にユーザー入力が直接含まれていないかCritical
出力エンコーディングHTML出力がコンテキストに応じてエスケープされているかHigh
出力エンコーディングCSP(Content Security Policy)が設定されているかMedium
依存関係新しい依存関係に既知の脆弱性がないかHigh
依存関係ライセンスの互換性が確認されているかMedium

静的解析ツールの統合

ツール比較

ツール種別言語対応CI統合特徴
SemgrepSAST30+言語GitHub Actions, GitLab CIカスタムルール作成が容易
CodeQLSAST12言語GitHub Advanced Securityデータフロー解析に優れる
SonarQubeSAST + 品質30+言語Jenkins, GitHub Actions品質メトリクスも統合
ESLint SecurityLintJS/TS全CI/CD軽量、高速

Semgrepカスタムルールの活用

# .semgrep/security-rules.yml
rules:
  # ルール1: eval()の使用禁止
  - id: no-eval-usage
    patterns:
      - pattern: eval($X)
    message: |
      eval()の使用は禁止されています。
      任意のコード実行のリスクがあります。
      代替: JSON.parse(), new Function() (それでも注意が必要)
    languages: [typescript, javascript]
    severity: ERROR
    metadata:
      cwe: "CWE-95: Improper Neutralization of Directives in Dynamically Evaluated Code"
      owasp: "A03:2021 - Injection"

  # ルール2: innerHTMLへの動的値の代入禁止
  - id: no-dynamic-innerhtml
    patterns:
      - pattern: $EL.innerHTML = $VALUE
      - pattern-not: $EL.innerHTML = ""
      - pattern-not: $EL.innerHTML = ''
    message: |
      innerHTMLへの動的値の代入はXSSリスクがあります。
      代替: textContent, createElement + appendChild
    languages: [typescript, javascript]
    severity: WARNING
    metadata:
      cwe: "CWE-79: Improper Neutralization of Input During Web Page Generation"

  # ルール3: ハードコードされたシークレットの検出
  - id: hardcoded-secret
    patterns:
      - pattern: |
          const $KEY = "..."
      - metavariable-regex:
          metavariable: $KEY
          regex: "(password|secret|apiKey|api_key|token|privateKey|private_key)"
    message: |
      シークレットがハードコードされています。
      環境変数またはシークレットマネージャーを使用してください。
    languages: [typescript, javascript]
    severity: ERROR
    metadata:
      cwe: "CWE-798: Use of Hard-coded Credentials"

  # ルール4: SQL文字列連結の検出
  - id: sql-string-concatenation
    patterns:
      - pattern: |
          $QUERY = `...${$VAR}...`
      - metavariable-regex:
          metavariable: $QUERY
          regex: ".*(SELECT|INSERT|UPDATE|DELETE|FROM|WHERE).*"
    message: |
      SQLクエリに変数を文字列連結しています。
      SQLインジェクションのリスクがあります。
      パラメータ化クエリまたはORMを使用してください。
    languages: [typescript, javascript]
    severity: ERROR
    metadata:
      cwe: "CWE-89: SQL Injection"

  # ルール5: 不適切なCORS設定の検出
  - id: cors-wildcard
    patterns:
      - pattern: |
          cors({ origin: "*" })
      - pattern: |
          cors({ origin: true })
    message: |
      CORSのoriginにワイルドカードが設定されています。
      許可するドメインを明示的に指定してください。
    languages: [typescript, javascript]
    severity: WARNING
    metadata:
      cwe: "CWE-942: Permissive Cross-domain Policy"

PR レビューへの自動統合

# .github/workflows/security-review.yml
name: Security Code Review

on:
  pull_request:
    types: [opened, synchronize]

jobs:
  semgrep-review:
    name: Semgrep Security Review
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Semgrep Scan
        uses: semgrep/semgrep-action@v1
        with:
          config: >-
            .semgrep/security-rules.yml
            p/owasp-top-ten
            p/typescript
          generateSarif: "1"

      - name: Upload SARIF
        if: always()
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: semgrep.sarif

  eslint-security:
    name: ESLint Security Check
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - name: ESLint Security Rules
        run: npx eslint --config .eslintrc.security.js 'src/**/*.ts'

コードレビューで発見すべき脆弱性パターン

パターン1: 不適切な認証チェック

// NG: 認証チェックの欠如
app.delete('/api/v1/users/:id', async (req, res) => {
  // 認証チェックなし!誰でもユーザーを削除できる
  await prisma.user.delete({ where: { id: req.params.id } });
  res.status(204).send();
});

// OK: 認証 + 認可チェック
app.delete('/api/v1/users/:id',
  authenticate,           // JWT検証
  authorize('admin'),     // 管理者権限チェック
  async (req, res) => {
    await prisma.user.delete({ where: { id: req.params.id } });
    res.status(204).send();
  },
);

パターン2: 機密データのログ出力

// NG: パスワードやトークンをログに出力
app.post('/api/v1/auth/login', async (req, res) => {
  console.log('Login attempt:', req.body); // パスワードが含まれる!
  const { email, password } = req.body;
  // ...
});

// OK: 機密データをマスク
app.post('/api/v1/auth/login', async (req, res) => {
  console.log('Login attempt:', { email: req.body.email });
  const { email, password } = req.body;
  // ...
});

// さらに良い: 構造化ログ + 自動マスク
import { logger } from './logger';

const sensitiveFields = ['password', 'token', 'creditCard', 'ssn'];

function maskSensitiveData(data: Record<string, unknown>): Record<string, unknown> {
  const masked = { ...data };
  for (const field of sensitiveFields) {
    if (field in masked) {
      masked[field] = '***REDACTED***';
    }
  }
  return masked;
}

パターン3: パストラバーサル

// NG: ユーザー入力をファイルパスに直接使用
app.get('/api/v1/files/:filename', async (req, res) => {
  const filePath = path.join('/uploads', req.params.filename);
  // 攻撃: GET /api/v1/files/../../etc/passwd
  res.sendFile(filePath);
});

// OK: パストラバーサル防止
import path from 'path';

app.get('/api/v1/files/:filename', async (req, res) => {
  const uploadsDir = path.resolve('/uploads');
  const requestedPath = path.resolve(uploadsDir, req.params.filename);

  // uploadsディレクトリ外へのアクセスを防止
  if (!requestedPath.startsWith(uploadsDir)) {
    return res.status(403).json({ error: 'Access denied' });
  }

  // ファイル名のバリデーション
  const safeFilename = path.basename(req.params.filename);
  if (safeFilename !== req.params.filename) {
    return res.status(400).json({ error: 'Invalid filename' });
  }

  res.sendFile(requestedPath);
});

パターン4: 安全でないデシリアライゼーション

// NG: 信頼できないデータのデシリアライゼーション
app.post('/api/v1/import', async (req, res) => {
  // YAML.loadはコード実行が可能な場合がある
  const data = yaml.load(req.body.yamlContent);
  // ...
});

// OK: 安全なデシリアライゼーション
import yaml from 'js-yaml';

app.post('/api/v1/import', async (req, res) => {
  // safeLoadは安全なスカラ型のみ許可
  const data = yaml.load(req.body.yamlContent, { schema: yaml.FAILSAFE_SCHEMA });

  // スキーマバリデーション
  const parseResult = ImportDataSchema.safeParse(data);
  if (!parseResult.success) {
    return res.status(400).json({ error: 'Invalid data format' });
  }
  // ...
});

パターン5: 競合状態(Race Condition)

// NG: チェックと実行の間に競合状態が発生
app.post('/api/v1/transfer', async (req, res) => {
  const { fromAccountId, toAccountId, amount } = req.body;

  const fromAccount = await prisma.account.findUnique({
    where: { id: fromAccountId },
  });

  if (fromAccount!.balance < amount) {
    return res.status(400).json({ error: 'Insufficient funds' });
  }

  // この間に別のリクエストが同時に実行されるとダブルスペンド!
  await prisma.account.update({
    where: { id: fromAccountId },
    data: { balance: { decrement: amount } },
  });

  await prisma.account.update({
    where: { id: toAccountId },
    data: { balance: { increment: amount } },
  });
});

// OK: トランザクションと楽観的ロック
app.post('/api/v1/transfer', async (req, res) => {
  const { fromAccountId, toAccountId, amount } = req.body;

  await prisma.$transaction(async (tx) => {
    // SELECT FOR UPDATE で排他ロック
    const fromAccount = await tx.$queryRaw`
      SELECT * FROM accounts WHERE id = ${fromAccountId} FOR UPDATE
    `;

    if (fromAccount[0].balance < amount) {
      throw new Error('Insufficient funds');
    }

    await tx.account.update({
      where: { id: fromAccountId },
      data: { balance: { decrement: amount } },
    });

    await tx.account.update({
      where: { id: toAccountId },
      data: { balance: { increment: amount } },
    });
  });

  res.json({ success: true });
});

セキュリティレビューの自動化と効率化

レビュワーアサイン戦略

# .github/CODEOWNERS
# セキュリティに関連するファイルはセキュリティチームがレビュー必須

# 認証・認可関連
/src/middleware/auth*      @security-team
/src/services/auth*        @security-team

# 暗号化・ハッシュ関連
/src/utils/crypto*         @security-team
/src/utils/hash*           @security-team

# データベースマイグレーション(スキーマ変更はセキュリティ影響を確認)
/prisma/migrations/        @security-team @db-admin

# インフラ設定
/infrastructure/           @security-team @platform-team

# CI/CD設定
/.github/workflows/        @security-team @platform-team

# 依存関係の変更
/package.json              @security-team
/package-lock.json         @security-team
セキュリティレビューのGitHub PR テンプレート
## セキュリティチェックリスト

### 変更内容に応じて該当項目をチェックしてください

#### 入力処理
- [ ] 外部入力にバリデーションを適用した
- [ ] ファイルアップロードの制限を確認した

#### 認証・認可
- [ ] 新規エンドポイントに認証チェックを追加した
- [ ] オブジェクトレベルの認可を確認した
- [ ] 管理者APIのアクセス制御を確認した

#### データ保護
- [ ] 機密データがログに出力されていないことを確認した
- [ ] シークレットがハードコードされていないことを確認した
- [ ] 暗号化の必要なデータが適切に保護されていることを確認した

#### 依存関係
- [ ] 新しい依存関係の脆弱性を確認した
- [ ] ライセンスの互換性を確認した

#### 該当しない場合
- [ ] この変更にセキュリティ上の影響はありません(理由: )

まとめ

ポイント内容
チェックリストカテゴリ別のセキュリティレビュー観点を標準化
静的解析統合Semgrep/CodeQL/SonarQube をCI/CDに組み込み、自動検出
脆弱性パターン認証欠如、ログ漏洩、パストラバーサル、デシリアライゼーション、競合状態
自動化CODEOWNERS、PRテンプレート、SARIF統合で効率化
Shift Leftコードレビュー段階で発見するコストは本番発見の1/100

チェックリスト

  • セキュリティコードレビューチェックリストを作成できる
  • Semgrepのカスタムルールを作成し、CI/CDに統合できる
  • 主要な脆弱性パターンをコードレビューで識別できる
  • CODEOWNERSとPRテンプレートを活用した自動化ができる
  • セキュリティレビューの文化をチームに浸透させる方法を説明できる

次のステップへ

次は「コンプライアンスフレームワーク」です。SOC2、ISO27001、PCI DSSなどのフレームワークを理解し、組織のコンプライアンス体制を設計しましょう。


推定読了時間: 30分