0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

npmとmavenのライフサイクル攻撃対応プロキシ

0
Last updated at Posted at 2026-06-20

ライフサイクル攻撃対応プロキシ

npmやmavenのライブラリが汚染されているかもしれないっていう不安から、自前のプロキシサーバを構築して対応しようと考え、以下の構成で実装してみました。

構成

[ 開発PC / CI/CD ]
       │ (mvn install / npm install)
       ▼
[ 検疫プロキシ (Python) ]  ※ここで「14日以内」をチェックして弾く
       │ (安全なら転送)
       ▼
[ Nexus OSS (Proxy) ]
       │ (キャッシュがなければ取得)
       ▼
[ 外部 (Maven Central / npmレジストリ) ]

dockerの設定

docker-compose.yml

services:
  # Nexus本体
  nexus:
    image: sonatype/nexus3:latest
    container_name: nexus
    restart: always
    volumes:
      - ./.docker/nexus-data:/nexus-data
    ports:
      - "61012:8081" # Nexus管理画面 兼 キャッシュサーバー
    networks:
      - proxy-net

  # 自作の検疫プロキシ(Python)
  quarantine-proxy:
    image: python:3.11-slim
    container_name: quarantine-proxy
    restart: always
    ports:
      - "61013:8080" # クライアント接続ポート
    volumes:
      - ./proxy_script:/app
    working_dir: /app
    command: sh -c "pip install flask requests && python app.py"
    networks:
      - proxy-net
    depends_on:
      - nexus

networks:
  proxy-net:

volumes:
  nexus-data:

あらかじめ、以下のディレクトリは200にしておきます。(nexusがうまく動かなかった)

sudo chown -R 200:200 ./.docker/nexus-data

python→nexus接続ユーザの作成

image.png

pythonスクリプト

※このスクリプトはあくまでサンプルですので、参考程度という事でお願いします。
正常動作しなかった場合はご了承ください。

./proxy_script/app.py
import base64
from datetime import datetime, timedelta
import re
from flask import Flask, Response, request
import requests

app = Flask(__name__)

# ==========================================
# 【設定情報】
# ==========================================
NEXUS_URL = "http://192.168.x.x:61012"
PROXY_USER = "proxy-user"
PROXY_PASSWORD = "xxxxxxx"

HOLD_DAYS = 14  # 検疫期間(14日間)

# Nexusへの認証ヘッダーを事前に作成 (Basic認証)
token = base64.b64encode(f"{PROXY_USER}:{PROXY_PASSWORD}".encode("utf-8")).decode(
    "utf-8"
)
NEXUS_HEADERS = {"Authorization": f"Basic {token}"}


# ==========================================
# 【検疫ロジック】公開日数をチェックする関数
# ==========================================
def is_safe_component(repo_type, path):
    try:
        # --- ① npm の検疫判定 ---
        if repo_type == "npm":
            # パスからパッケージ名とバージョンを抽出する正規表現
            # 例: /repository/npm-public/vue/-/vue-3.5.0.tgz -> name: vue, version: 3.5.0
            match = re.search(r"([^/]+)/-/.*-(\d+\.\d+\.\d+(-\w+)?)\.tgz", path)
            if not match:
                return True  # 該当のメタデータ構造でない場合は一旦通す(またはメタデータjson)

            package_name, version = match.group(1), match.group(2)

            # スコープ付きパッケージ(例: @types/node)のハンドリング
            if "/@" in path:
                scope_match = re.search(r"(@[^/]+/[^/]+)/-/.*", path)
                if scope_match:
                    package_name = scope_match.group(1)

            # npm公式APIに問い合わせ
            url = f"https://registry.npmjs.org/{package_name}"
            res = requests.get(url, timeout=5).json()
            time_str = res.get("time", {}).get(version)

            if time_str:
                # ISO 8601形式 (2026-06-19T...Z) をパース
                release_date = datetime.strptime(time_str[:10], "%Y-%m-%d")
                if datetime.now() - release_date < timedelta(days=HOLD_DAYS):
                    print(
                        f"[⚠️ BLOCK] npm: {package_name}@{version} はリリース後 {HOLD_DAYS} 日以内です。"
                    )
                    return False

        # --- ② Maven の検疫判定 ---
        elif repo_type == "maven":
            if not path.endswith((".jar", ".pom")):
                return True

            # 先頭の "/repository/リポジトリ名/" を取り除く
            match_maven = re.match(r"^/repository/[^/]+/(.*)$", path)
            maven_path = match_maven.group(1) if match_maven else path.lstrip("/")

            # Maven Central 本尊のURLを構築
            central_url = f"https://repo1.maven.org/maven2/{maven_path}"

            # Maven Central本体に直接 HEAD リクエストを送り、ファイルの正確なタイムスタンプを取得する
            headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"}
            central_res = requests.head(central_url, headers=headers, timeout=5)

            if central_res.status_code == 200:
                last_modified_str = central_res.headers.get("Last-Modified")
                if last_modified_str:
                    from email.utils import parsedate_to_datetime

                    release_date = parsedate_to_datetime(last_modified_str).replace(tzinfo=None)

                    if datetime.now() - release_date < timedelta(days=HOLD_DAYS):
                        print(f"[⚠️ BLOCK] Maven: {maven_path} はリリース後 {HOLD_DAYS} 日以内です。({release_date})")
                        return False
                    else:
                        print(f"[INFO] Maven: {maven_path} は14日以上経過しているため安全です。({release_date})")
                        return True

            # 安全側に倒して、検疫ブロックします
            print(f"[⚠️ BLOCK] Maven: {maven_path} は Maven Central 上に存在しない、または確認できないためブロックします。")
            return False

    except Exception as e:
        # APIエラー等の場合は安全側に倒してブロック
        print(f"[ERROR] 検疫チェック中にエラーが発生: {e}")
        return False

    return True

# ==========================================
# 【プロキシメイン処理】どんなパスでも一律で受け止める
# ==========================================
@app.route("/", defaults={"path": ""}, methods=["GET", "HEAD"])
@app.route("/<path:path>", methods=["GET", "HEAD"])
def catch_all(path):
    # 頭にスラッシュをつけて、重複するスラッシュ(//)を1つに綺麗に成形する
    full_path = "/" + path
    full_path = re.sub(r"//+", "/", full_path)

    match = re.match(r"^/repository/([^/]+)/(.*)$", full_path)
    if not match:
        target_url = f"{NEXUS_URL}{full_path}"
    else:
        repo_name = match.group(1)
        target_path = match.group(2)

        repo_type = "unknown"
        if "npm" in repo_name:
            repo_type = "npm"
        elif "maven" in repo_name:
            repo_type = "maven"

        if request.method in ["GET", "HEAD"]:
            if not is_safe_component(repo_type, full_path):
                return Response(
                    "🚫 [Quarantine] Access Denied: Under 14-day quarantine.",
                    status=403,
                )

        target_url = f"{NEXUS_URL}{full_path}"

    if request.query_string:
        target_url += f"?{request.query_string.decode('utf-8')}"

    try:
        # Nexusへリレー通信
        nexus_response = requests.request(
            method=request.method,
            url=target_url,
            auth=(PROXY_USER, PROXY_PASSWORD),
            stream=True,
            timeout=30,
        )

        if request.method == "HEAD":
            response = Response(
                status=nexus_response.status_code,
                content_type=nexus_response.headers.get("Content-Type"),
            )
            for key, value in nexus_response.headers.items():
                if key.lower() not in [
                    "content-length",
                    "transfer-encoding",
                    "connection",
                ]:
                    response.headers[key] = value
            return response

        # --- GETリクエストの処理 ---
        content_type = nexus_response.headers.get("Content-Type", "")
        if "application/json" in content_type and "/repository/" in full_path:
            proxy_root_url = request.host_url.rstrip("/")

            json_text = nexus_response.text
            modified_json_text = json_text.replace(NEXUS_URL, proxy_root_url)

            response = Response(
                modified_json_text,
                status=nexus_response.status_code,
                content_type=content_type,
            )
            return response

        response = Response(
            nexus_response.iter_content(chunk_size=10 * 1024),
            status=nexus_response.status_code,
            content_type=content_type,
        )
        return response

    except Exception as e:
        import traceback

        print(f"[🚨 CRITICAL ERROR]\n{traceback.format_exc()}")
        return Response("Internal Proxy Error", status=500)


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080, debug=True)

動作確認 maven

クライアント設定

クライアント側では、~/.m2/settings.xmlを作成してプロキシ経由にします

<?xml version="1.0" encoding="UTF-8"?>
<settings xmlns="http://maven.apache.org/SETTINGS/1.2.0"
          xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
          xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.2.0 https://maven.apache.org/xsd/settings-1.2.0.xsd">

  <mirrors>
    <mirror>
      <id>company-quarantine-mirror</id>
      <name>Company Quarantine Proxy Mirror</name>
      <url>http://192.168.x.x:61013/repository/maven-public/</url>
      <mirrorOf>*</mirrorOf>
    </mirror>
  </mirrors>

  <profiles>
    <profile>
      <id>quarantine-profile</id>
      <repositories>
        <repository>
          <id>central</id>
          <url>https://repo.maven.apache.org/maven2</url>
          <snapshots>
            <enabled>false</enabled>
          </snapshots>
        </repository>
      </repositories>
    </profile>
  </profiles>

  <activeProfiles>
    <activeProfile>quarantine-profile</activeProfile>
  </activeProfiles>

</settings>

jackson-databindで実験します。

image.png

  • 3.1.4 は 2026/5/29 なので、今日(2026/6/20)より14以上前なので正常に取れる
% mvn dependency:get -Dartifact=tools.jackson.core:jackson-databind:3.1.4
[INFO] Scanning for projects...
[INFO]
[INFO] ------------------< org.apache.maven:standalone-pom >-------------------
[INFO] Building Maven Stub Project (No POM) 1
[INFO] --------------------------------[ pom ]---------------------------------
[INFO]
[INFO] --- dependency:3.6.1:get (default-cli) @ standalone-pom ---
[INFO] Resolving tools.jackson.core:jackson-databind:jar:3.1.4 with transitive dependencies
Downloading from company-quarantine-mirror: http://192.168.x.x:61013/repository/maven-public/tools/jackson/core/jackson-databind/3.1.4/jackson-databind-3.1.4.pom
Downloaded from company-quarantine-mirror: http://192.168.x.x:61013/repository/maven-public/tools/jackson/core/jackson-databind/3.1.4/jackson-databind-3.1.4.pom (18 kB at 52 kB/s)
<中略>
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  3.867 s
[INFO] Finished at: 2026-06-20T20:09:14+09:00
[INFO] ------------------------------------------------------------------------
  • 3.2.0 は 2026/6/9 なので、今日(2026/6/20)より14日以内なので取れない
% mvn dependency:get -Dartifact=tools.jackson.core:jackson-databind:3.2.0
[INFO] Scanning for projects...
[INFO]
[INFO] ------------------< org.apache.maven:standalone-pom >-------------------
[INFO] Building Maven Stub Project (No POM) 1
[INFO] --------------------------------[ pom ]---------------------------------
[INFO]
[INFO] --- dependency:3.6.1:get (default-cli) @ standalone-pom ---
[INFO] Resolving tools.jackson.core:jackson-databind:jar:3.2.0 with transitive dependencies
Downloading from company-quarantine-mirror: http://192.168.x.x:61013/repository/maven-public/tools/jackson/core/jackson-databind/3.2.0/jackson-databind-3.2.0.pom
[INFO] ------------------------------------------------------------------------
[INFO] BUILD FAILURE
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  1.873 s
[INFO] Finished at: 2026-06-20T20:08:26+09:00
[INFO] ------------------------------------------------------------------------
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-dependency-plugin:3.6.1:get (default-cli) on project standalone-pom: Couldn't download artifact: org.eclipse.aether.resolution.DependencyResolutionException: Failed to read artifact descriptor for tools.jackson.core:jackson-databind:jar:3.2.0: The following artifacts could not be resolved: tools.jackson.core:jackson-databind:pom:3.2.0 (absent): Could not transfer artifact tools.jackson.core:jackson-databind:pom:3.2.0 from/to company-quarantine-mirror (http://192.168.x.x:61013/repository/maven-public/): status code: 403, reason phrase: FORBIDDEN (403) -> [Help 1]
[ERROR]
[ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch.
[ERROR] Re-run Maven using the -X switch to enable full debug logging.
[ERROR]
[ERROR] For more information about the errors and possible solutions, please read the following articles:
[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoExecutionException

動作確認 npm

クライアント設定

クライアント側では、.npmrcを作成してプロキシ経由にします

.npmrc
registry=http://192.168.x.x:61013/repository/npm-public/
//192.168.x.x:61013/repository/npm-public/:_auth=false

@aws-sdk/client-s3で実験します。

image.png

  • 3.1063.0 は 15 days ago で14以上前なので正常に取れる
>npm install @aws-sdk/client-s3@3.1063.0

added 45 packages in 5s

7 packages are looking for funding
  run `npm fund` for details
  • 3.1064.0 は 12 days ago で14日以内なので取れない
>npm install @aws-sdk/client-s3@3.1064.0
npm error code E403
npm error 403 403 FORBIDDEN - GET http://192.168.x.x:61013/repository/npm-public/@aws-sdk/client-s3/-/client-s3-3.1064.0.tgz
npm error 403 In most cases, you or one of your dependencies are requesting
npm error 403 a package version that is forbidden by your security policy, or
npm error 403 on a server you do not have access to.
npm error A complete log of this run can be found in: C:\Users\xxxxx\AppData\Local\npm-cache\_logs\2026-06-20T11_44_02_306Z-debug-0.log

あとがき

14日とした理由は特にないが、一旦これで運用してみて課題がありそうだったら考える予定です

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?