2026年1月7日水曜日

pywebpush で鍵を作成する方法

pywebpush で鍵を作成する方法

概要

pywebpush は Python からブラウザプッシュを実現する仕組みです
プッシュ通知するには秘密鍵と公開鍵の作成が必要です
今回はそれらの鍵の作成方法を紹介します

環境

  • macOS 15.7.2
  • Python 3.12.11
    • pywebpush 2.2.0
    • py-vapid 1.9.4

インストール

  • pipenv install py-vapid

鍵ファイル作成

  • pipenv run vapid --gen

これで private_key.pem と public_key.pem が作成できます
private_key.pem は Python 側のアプリで設定します
ファイルのパスをそのまま指定すれば OK です

webpush(
    subscription_info=json.loads(sub),
    data=json.dumps(
        {"title": "新着メッセージ", "body": "相手からメッセージが届きました"}
    ),
    vapid_private_key=VAPID_PRIVATE_KEY_PATH,
    vapid_claims={"sub": "user01@mail"},
)

公開鍵の文字列化

  • pipenv run vapid --applicationServerKey

ここで文字列化した情報は JavaScript 側に設定します

const sub = await reg.pushManager.subscribe({
  userVisibleOnly: true,
  applicationServerKey:
    "xxx",
});

最後に

ブラウザプッシュは https が必須なので注意しましょう
localhost はテスト用で動作しますが IP や http していないサイトでは動作しません

2026年1月5日月曜日

Github copilot Pro のトライアルを解約する方法

Github copilot Pro のトライアルを解約する方法

概要

手順がコロコロ変わるらしいです

環境

  • Github copilot (2026/01/05時点)

解約手順

最後に

トライアル期間の30日を過ぎると自動で10ドル課金になるのでトライアル中にキャンセルするようにしましょう

2025年12月19日金曜日

Python のプロジェクトでやっておくべきセキュリティテスト

Python のプロジェクトでやっておくべきセキュリティテスト

概要

簡単にできるのでとりあえず入れておいて損はしないはず

環境

  • macOS 15.7.2
  • Python 3.12.11
    • bandit 1.9.2
    • safety 3.7.0
    • sqlmap 1.8.2

bandit

  • pipenv install -d bandit
  • pipenv run bandit -r .

セキュアコードのチェックをしてくれます
os.system などインジェクションの危険があるコードを検知できます
random (セキュリティ用途) や assert などの使い方でも警告してくれます

safety

  • pipenv install -d safety
  • pipenv run safety scan

ライブラリなどのセキュリティチェックをしてくれます
アカウント登録が必要なのが辛いです
執筆時点では safety check が DEPRECATED ですが一応使えてこれならアカウント登録不要で使えます

zaproxy

Web アプリであれば導入しましょう
実際にアプリを起動してアプリに対してペネトレーションテストを実行してくれます

https://hawksnowlog.blogspot.com/2025/07/run-zaproxy-with-docker-penetration-test.html

nikto

こちらも Web アプリ用のペネトレーションテストです
zaproxy と結果が異なるので一応入れておいてもいいかもです

https://hawksnowlog.blogspot.com/2025/07/penetration-test-with-nikto-on-docker.html

sqlmap

SQLインジェクションに特化したペネトレーションテストです
公式の docker イメージなどはないようですが parootsec のスポンサードイメージがあるのでそれを使うのが簡単です

--level 5 --risk 3 --batch オプションを追加してもいいかもです

trivy

生成した docker イメージなどに対して行います
インストールされているモジュールやツールが脆弱性を含むバージョンでないかをチェックしてくれます

https://hawksnowlog.blogspot.com/2022/12/trivy.html

Metasploit

Python アプリと言うよりかはホストマシン側のペネトレーションテストです

https://hawksnowlog.blogspot.com/2025/08/how-to-run-metasploit-on-docker.html

fuzzing

特定の関数に対してメモリのオーバフローテストができます

https://hawksnowlog.blogspot.com/2025/12/python-fuzzing-test.html

Python 自体のユニットテスト

ユニットテストやシステムテストでインジェクション系のクエリを投げてテストします

mport pytest
from markupsafe import escape

def test_xss_prevention():
    """ユーザー入力が正しくエスケープされるか"""
    user_input = "<script>alert('xss')</script>"
    result = escape(user_input)
    assert "<script>" not in result
    assert "&lt;script&gt;" in result

def test_sql_injection_prevention(db):
    """SQLインジェクション対策の確認"""
    # SQLAlchemy のバインドパラメータを使用しているか
    malicious_input = "1' OR '1'='1"
    result = db.session.execute(
        text("SELECT * FROM users WHERE id = :id"),
        {"id": malicious_input}
    ).fetchall()
    # 結果は期待値と一致するか確認
    assert len(result) == 0

def test_password_hashing():
    """パスワードがハッシュ化されているか"""
    from werkzeug.security import check_password_hash, generate_password_hash
    password = "test_password_123"
    hashed = generate_password_hash(password)
    assert hashed != password
    assert check_password_hash(hashed, password)

def test_file_upload_validation():
    """ファイルアップロードが検証されるか"""
    from pathlib import Path
    import io
    # 許可されていないファイル型
    fake_pdf = io.BytesIO(b"not a real pdf")
    filename = "malicious.pdf"
    # バリデーション処理が拒否するか確認
    assert not validate_upload(fake_pdf, filename)

flask なら以下のような感じです

import pytest
from flask import Flask
from app import create_app

@pytest.fixture
def client():
    app = create_app()
    app.config['TESTING'] = True
    with app.test_client() as client:
        yield client

def test_csrf_protection(client):
    """CSRF トークン検証"""
    response = client.get('/form')
    assert 'csrf_token' in response.data.decode()
    
    # CSRF トークンなしで POST → 403 エラー
    response = client.post('/form', data={'field': 'value'})
    assert response.status_code == 403

def test_authentication_required(client):
    """認証なしで保護されたエンドポイントアクセス"""
    response = client.get('/dashboard')
    assert response.status_code == 302  # リダイレクト
    assert '/login' in response.location

def test_path_traversal_prevention(client):
    """パストラバーサル攻撃の防止"""
    response = client.get('/files?file=../../etc/passwd')
    assert response.status_code == 403 or response.status_code == 404

def test_injection_attacks(client):
    """SQLインジェクション攻撃の防止"""
    malicious = "1' OR '1'='1'; DROP TABLE users; --"
    response = client.get(f'/search?q={malicious}')
    # 結果が期待通りで、テーブルが削除されていないか確認
    assert response.status_code == 200
    assert 'results' in response.json

有料もしくはアカウント登録必要

SonarQube

Web GUI でテストできます
導入が面倒そうです

Burp Suite

クライアント GUI でテストできます
ペネトレーションテストツールです
かなり複雑な攻撃までできるようです

SBOM (snyk)

trivy のように CLI でチェックできます

最後に

セキュリティ系のテストは一つではなく複数を組み合わせてやることで強度が増します

2025年12月18日木曜日

Python で fuzzing テスト

Python で fuzzing テスト

概要

メモリのオーバフローテストなどできます

環境

  • Ubuntu 24.04
  • Python 3.12.10
  • atheris 3.0.0

テスト対象のコード

  • vim fuzzing.py
import ipaddress


def get_ipaddress(data: dict) -> str | None:
    """
    入力のdictから 'ipaddress' キーの値を取得して返却する。
    
    Args:
        data: 入力辞書
        
    Returns:
        str | None: 有効なIPアドレス、またはキーが存在しない場合はNone
        
    Raises:
        ValueError: IPアドレスのフォーマットが不正な場合
    """
    if "ipaddress" not in data:
        return None
    
    ip_str = data["ipaddress"]
    
    # IPアドレスのフォーマットをチェック
    try:
        # IPv4 または IPv6 アドレスとして検証
        ipaddress.ip_address(ip_str)
        return ip_str
    except ValueError as e:
        raise ValueError(f"Invalid IP address format: {ip_str}") from e

テストコード

  • vim test_fuzzing.py
import sys
import atheris
from fuzzing import get_ipaddress


@atheris.instrument_func
def test_get_ipaddress(data):
    """
    get_ipaddress関数のファジングテスト
    """
    if len(data) < 1:
        return
    
    # ランダムなバイトデータを文字列に変換
    try:
        input_str = data.decode('utf-8', errors='ignore')
    except Exception:
        return
    
    # テストケース1: キーが存在しない場合
    try:
        result = get_ipaddress({})
        assert result is None
    except Exception:
        pass
    
    # テストケース2: ipaddressキーに様々な値を設定
    try:
        result = get_ipaddress({"ipaddress": input_str})
        # 結果が返ってきた場合は文字列であることを確認
        if result is not None:
            assert isinstance(result, str)
    except ValueError:
        # フォーマットエラーは想定内
        pass
    except Exception as e:
        # その他の予期しない例外は記録
        print(f"Unexpected exception: {type(e).__name__}: {e}")
        raise
    
    # テストケース3: 他のキーも含む辞書
    try:
        test_dict = {
            "other_key": "value",
            "ipaddress": input_str,
            "another_key": 123
        }
        result = get_ipaddress(test_dict)
        if result is not None:
            assert isinstance(result, str)
    except ValueError:
        pass
    except Exception as e:
        print(f"Unexpected exception in test case 3: {type(e).__name__}: {e}")
        raise


def main():
    """
    ファジングテストのエントリーポイント
    """
    atheris.Setup(sys.argv, test_get_ipaddress)
    atheris.Fuzz()


if __name__ == "__main__":
    main()

おまけ: クラスの場合

import ipaddress


class IPAddressValidator:
    """IPアドレスの検証を行うクラス"""

    def get_ipaddress(self, data: dict) -> str | None:
        """
        入力のdictから 'ipaddress' キーの値を取得して返却する。

        Args:
            data: 入力辞書

        Returns:
            str | None: 有効なIPアドレス、またはキーが存在しない場合はNone

        Raises:
            ValueError: IPアドレスのフォーマットが不正な場合
        """
        if "ipaddress" not in data:
            return None

        ip_str = data["ipaddress"]

        # IPアドレスのフォーマットをチェック
        try:
            # IPv4 または IPv6 アドレスとして検証
            ipaddress.ip_address(ip_str)
            return ip_str
        except ValueError as e:
            raise ValueError(f"Invalid IP address format: {ip_str}") from e
import sys

import atheris
from fuzzing import IPAddressValidator


class FuzzingTest:
    """IPAddressValidatorのファジングテストクラス"""

    def __init__(self):
        self.validator = IPAddressValidator()

    @atheris.instrument_func
    def test_get_ipaddress(self, data):
        """
        get_ipaddressメソッドのファジングテスト
        """
        if len(data) < 1:
            return

        # ランダムなバイトデータを文字列に変換
        try:
            input_str = data.decode("utf-8", errors="ignore")
        except Exception:
            return

        # テストケース1: キーが存在しない場合
        try:
            result = self.validator.get_ipaddress({})
            assert result is None
        except Exception:
            pass

        # テストケース2: ipaddressキーに様々な値を設定
        try:
            result = self.validator.get_ipaddress({"ipaddress": input_str})
            # 結果が返ってきた場合は文字列であることを確認
            if result is not None:
                assert isinstance(result, str)
        except ValueError:
            # フォーマットエラーは想定内
            pass
        except Exception as e:
            # その他の予期しない例外は記録
            print(f"Unexpected exception: {type(e).__name__}: {e}")
            raise

        # テストケース3: 他のキーも含む辞書
        try:
            test_dict = {
                "other_key": "value",
                "ipaddress": input_str,
                "another_key": 123,
            }
            result = self.validator.get_ipaddress(test_dict)
            if result is not None:
                assert isinstance(result, str)
        except ValueError:
            pass
        except Exception as e:
            print(f"Unexpected exception in test case 3: {type(e).__name__}: {e}")
            raise


def main():
    """
    ファジングテストのエントリーポイント
    """
    fuzzing_test = FuzzingTest()
    atheris.Setup(sys.argv, fuzzing_test.test_get_ipaddress)
    atheris.Fuzz()


if __name__ == "__main__":
    main()

おまけ: pytest で書く

なさそうです

最後に

テスト中は CPU 100% になるので注意しましょう
かなり負荷がかかるテストをするのでデータベースや外部にリクエストする関数をテストする場合は必ずモックするようにしましょう

またテストの量も膨大で上記のコードでも30分くらいかかりました
速く終わらせたい場合はスペックのあるマシン上でテストしてください

参考サイト

2025年12月17日水曜日

fail2ban で ban 情報を永続化する方法

fail2ban で ban 情報を永続化する方法

概要

これまでの方法だと docker compose down -> up -d すると ban 情報もクリアされてしまうので永続化する方法を紹介します

環境

  • Ubuntu 24.04
  • docker
  • linuxserver/fail2ban 1.1.0-r2-ls26
  • fail2ban 1.1.0

修正箇所

  • /config/fail2ban/fail2ban.sqlite3 を永続化する
  • fail2ban.sqlite3 をホスト側に先に作成する (ファイルがないとディレクトリマウントになり fail2ban の起動に失敗するため)

前回からの差分

先に

  • mkdir data
  • touch data/fail2ban.sqlite3

をしておきます
そして以下を書き換えて down -> up -d すれば OK です

diff --git a/compose.yaml b/compose.yaml
index 7a8a33f..5381790 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -20,6 +20,7 @@ services:
       - ./config/jail.local:/config/fail2ban/jail.d/jail.local
       - ./config/filter.d/nginx-404.conf:/config/fail2ban/filter.d/nginx-404.conf
       - ./config/action.d/slack.conf:/config/fail2ban/action.d/slack.conf
+      - ./data/fail2ban.sqlite3:/config/fail2ban/fail2ban.sqlite3
       - ./nginx/log:/remotelogs/nginx:ro  # nginxログを読み取り専用で監視
     environment:
       - PUID=1000  # 自身のユーザIDとグループIDに合わせて変更 (config/ 配下の権限がコンテナ側のユーザIDで変更されてしまうため編集ができなくなる)

最後に

sqlite3 のファイルを保持しましょう
down 中は解放状態になるので注意しましょう

2025年12月16日火曜日

fail2ban だけ network_mode: host で動作させる方法

fail2ban だけ network_mode: host で動作させる方法

概要

前回の続きです
なぜか nginx も network_mode: host にしていたので nginx は docker ネットワーク上で動作させます

環境

  • Ubuntu 24.04
  • docker
  • linuxserver/fail2ban 1.1.0-r2-ls26
  • fail2ban 1.1.0

修正箇所

  • jail.local の chain を DOCKER-USER に変更
  • nginx コンテナの network_mode を削除し ports でバインドするように変更

前回からの差分

diff --git a/compose.yaml b/compose.yaml
index 1f4124c..7a8a33f 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -2,7 +2,9 @@ services:
   nginx:
     image: nginx:latest
     container_name: nginx
-    network_mode: host
+    ports:
+      - "80:80"
+      - "443:443"
     volumes:
       - ./nginx/log:/var/log/nginx  # このログをfail2banで監視
     restart: unless-stopped
diff --git a/config/jail.local b/config/jail.local
index 82c38b7..3e40cb0 100644
--- a/config/jail.local
+++ b/config/jail.local
@@ -78,7 +78,7 @@ action  = %(action_)s
 [nginx-404]
 # Custom filter to detect excessive 404 errors
 enabled  = true
-chain    = INPUT
+chain    = DOCKER-USER
 port     = http,https
 filter   = nginx-404
 logpath  = /remotelogs/nginx/access.log

最後に

chain は iptables の chain に紐づいています
なぜ最初からこうしなかったのだろうか

2025年12月15日月曜日

fail2ban で ban の際に slack に通知する

fail2ban で ban の際に slack に通知する

概要

前回続きです
ban になったら slack に通知するようにしてみました

環境

  • Ubuntu 24.04
  • docker
  • linuxserver/fail2ban 1.1.0-r2-ls26
  • fail2ban 1.1.0

compose.yaml

ユーザの権限部分など少し変更しています
作成したアクションの設定ファイルもマウントするようにしています

services:
  nginx:
    image: nginx:latest
    container_name: nginx
    network_mode: host
    volumes:
      - ./nginx/log:/var/log/nginx  # このログをfail2banで監視
    restart: unless-stopped

  fail2ban:
    image: linuxserver/fail2ban:latest
    container_name: fail2ban
    network_mode: host
    cap_add:
      - NET_ADMIN # BANに必須
      - NET_RAW   # BANに必須
    volumes:
      - ./config/jail.local:/config/fail2ban/jail.d/jail.local
      - ./config/filter.d/nginx-404.conf:/config/fail2ban/filter.d/nginx-404.conf
      - ./config/action.d/slack.conf:/config/fail2ban/action.d/slack.conf
      - ./nginx/log:/remotelogs/nginx:ro  # nginxログを読み取り専用で監視
    environment:
      - PUID=1000  # 自身のユーザIDとグループIDに合わせて変更 (config/ 配下の権限がコンテナ側のユーザIDで変更されてしまうため編集ができなくなる)
      - PGID=122
      - TZ=Asia/Tokyo
      - VERBOSITY=-vv
    restart: unless-stopped

./config/jail.conf

nginx-404 サービスの action に slack を追加します

[DEFAULT]

# "bantime.increment" allows to use database for searching of previously banned ip's to increase a
# default ban time
bantime.increment = true

# "bantime.maxtime" is the max number of seconds using the ban time can reach (doesn't grow further)
bantime.maxtime = 5w

# "bantime.factor" is a coefficient to calculate exponent growing of the formula or common multiplier
bantime.factor = 24

# "bantime" is the number of seconds that a host is banned.
bantime = 1h

# A host is banned if it has generated "maxretry" during the last "findtime"
# seconds.
findtime = 24h

# "maxretry" is the number of failures before a host get banned.
maxretry = 5

# Prevents banning LAN subnets
ignoreip    = 127.0.0.1/8 ::1
              10.0.0.0/8
              172.16.0.0/12
              192.168.0.0/16

# The ban action "iptables-multiport" (default) should work for most
# The ban action "iptables-allports" can be used if multiport causes issues
banaction = iptables-multiport
banaction_allports = iptables-allports

# Read https://github.com/sebres/PoC/blob/master/FW.IDS-DROP-vs-REJECT/README.md before changing block type
# The block type "REJECT --reject-with icmp-port-unreachable" (default behavior) should respond to, but then instantly reject connection attempts
# The block type "DROP" should not respond to connection attempts, resulting in a timeout
#banaction = iptables-multiport[blocktype=DROP]

# Add additional actions
action  = %(action_)s

# [sshd]
# configuration inherits from jail.conf
# enabled = true
# chain   = INPUT
# action  = %(action_)s

[nginx-http-auth]
# configuration inherits from jail.conf
enabled = true
chain   = DOCKER-USER
action  = %(action_)s

[nginx-badbots]
# configuration inherits from jail.d/nginx-badbots.conf 
enabled = true
chain   = DOCKER-USER
action  = %(action_)s

[nginx-botsearch]
# configuration inherits from jail.conf
enabled = true
chain   = DOCKER-USER
action  = %(action_)s

[nginx-deny]
# configuration inherits from jail.d/nginx-deny.conf
enabled = true
chain   = DOCKER-USER
action  = %(action_)s

[nginx-unauthorized]
# configuration inherits from jail.d/nginx-unauthorized.conf
enabled = true
chain   = DOCKER-USER
action  = %(action_)s

[nginx-404]
# Custom filter to detect excessive 404 errors
enabled  = true
chain    = INPUT
port     = http,https
filter   = nginx-404
logpath  = /remotelogs/nginx/access.log
maxretry = 3
findtime = 10s
bantime  = 1h
action   = %(action_)s
           slack

config/filter.d/nginx-404.conf

前回と同じです

config/action.d/slack.conf

各種アクション時に通知するようにします
webhook_url は変更してください

# Fail2Ban configuration file
#
# Action to send notifications to Slack
#

[Definition]

# Option: actionstart
# Notes.: command executed once at the start of Fail2Ban.
actionstart = curl -X POST -H 'Content-type: application/json' \
              --data '{"text":"[Fail2Ban] <name> jail has started"}' \
              <slack_webhook_url>

# Option: actionstop
# Notes.: command executed once at the end of Fail2Ban
actionstop = curl -X POST -H 'Content-type: application/json' \
             --data '{"text":"[Fail2Ban] <name> jail has stopped"}' \
             <slack_webhook_url>

# Option: actioncheck
# Notes.: command executed once before each actionban command
actioncheck =

# Option: actionban
# Notes.: command executed when banning an IP. Take care that the
#         command is executed with Fail2Ban user rights.
actionban = curl -X POST -H 'Content-type: application/json' \
            --data '{"text":"[Fail2Ban] :no_entry: <name> jail banned IP: *<ip>*\nFailures: <failures>\nTime: <time>"}' \
            <slack_webhook_url>

# Option: actionunban
# Notes.: command executed when unbanning an IP. Take care that the
#         command is executed with Fail2Ban user rights.
actionunban = curl -X POST -H 'Content-type: application/json' \
              --data '{"text":"[Fail2Ban] :white_check_mark: <name> jail unbanned IP: *<ip>*"}' \
              <slack_webhook_url>

[Init]

# Slack webhook URL
slack_webhook_url = https://hooks.slack.com/services/xxx/xxx/xxx

動作確認

  • docker compose up -d

あとは localhost/test などで 404 を発生させると通知が来ます
unbanip で ban 解除しても通知が来ます

トラブルシューティング

起動時にちゃんと追加したアクションが読み込まれていることを確認しましょう
ここで読み込まれていないとうまく動作しません

  • docker compose logs | grep ‘action’
fail2ban  |  2025-12-12 10:00:26,132 79986F9C2B28 INFO  Loading configs for action.d/iptables-multiport under /etc/fail2ban
fail2ban  |  2025-12-12 10:00:26,133 79986F9C2B28 DEBUG Reading configs for action.d/iptables-multiport under /etc/fail2ban
fail2ban  |  2025-12-12 10:00:26,133 79986F9C2B28 DEBUG Reading config files: /etc/fail2ban/action.d/iptables-multiport.conf
fail2ban  |  2025-12-12 10:00:26,133 79986F9C2B28 INFO    Loading files: ['/etc/fail2ban/action.d/iptables-multiport.conf']
fail2ban  |  2025-12-12 10:00:26,133 79986F9C2B28 INFO    Loading files: ['/etc/fail2ban/action.d/iptables.conf']
fail2ban  |  2025-12-12 10:00:26,134 79986F9C2B28 INFO    Loading files: ['/etc/fail2ban/action.d/iptables.conf', '/etc/fail2ban/action.d/iptables-multiport.conf']
fail2ban  |  2025-12-12 10:00:26,147 79986F9C2B28 INFO  Loading configs for action.d/slack under /etc/fail2ban
fail2ban  |  2025-12-12 10:00:26,147 79986F9C2B28 DEBUG Reading configs for action.d/slack under /etc/fail2ban
fail2ban  |  2025-12-12 10:00:26,147 79986F9C2B28 DEBUG Reading config files: /etc/fail2ban/action.d/slack.conf
fail2ban  |  2025-12-12 10:00:26,147 79986F9C2B28 INFO    Loading files: ['/etc/fail2ban/action.d/slack.conf']
fail2ban  |  2025-12-12 10:00:26,147 79986F9C2B28 INFO    Loading files: ['/etc/fail2ban/action.d/slack.conf']

最後に

ban になったかどうかをいちいち確認する必要がなくなるので便利です
現在の ban 状況を定期的に送信してくれる機能があると更に便利かなと思います