Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Repository migration details are documented in [docs/repository-history.md](docs
- Add a new system: [docs/guides/add-site.md](docs/guides/add-site.md)
- Add estimation support: [docs/guides/add-estimation.md](docs/guides/add-estimation.md)
- CI execution control: [docs/ci.md](docs/ci.md)
- Security policy: [SECURITY.md](SECURITY.md)
- Profiler support guide: [docs/guides/profiler-support.md](docs/guides/profiler-support.md)
- Profiler level reference: [docs/guides/profiler-level-reference.md](docs/guides/profiler-level-reference.md)

Expand Down
50 changes: 50 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Security Policy

BenchKit is published as open source, so security fixes and reporting paths
need to be clear for external users and researchers.

## Supported Versions

| Version or branch | Supported |
| --- | --- |
| `main` | Yes |
| `develop` | Yes, for upcoming fixes before release |
| Older untagged revisions | No |

## Reporting a Vulnerability

Please report suspected vulnerabilities privately instead of opening a public
issue.

- GitHub private vulnerability reports: https://github.com/RIKEN-RCCS/benchkit/security/advisories/new

Include the affected component, impact, reproduction steps, proof of concept
details if available, and any suggested fix. Do not include real secrets,
credentials, or personal data in the report.

## Response Targets

| Step | Target |
| --- | --- |
| Initial acknowledgement | Within 3 business days |
| Triage | Within 7 business days |
| Critical or High severity patch | Within 30 days |
| Medium severity patch | Within 90 days |
| Coordinated disclosure | After a fix is available, usually within 30 to 90 days |

## Scope

In scope:

- `result_server` authentication, authorization, ingestion, and portal routes
- CI and runner integration that could expose credentials or corrupt results
- Deployment guidance that could lead to insecure production defaults

Out of scope:

- Social engineering
- Attacks requiring already-compromised infrastructure outside this repository
- Vulnerabilities in third-party dependencies that should be reported upstream
- Local development configurations intentionally bound to loopback interfaces

We appreciate coordinated disclosure and will credit reporters when requested.
5 changes: 5 additions & 0 deletions docs/ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,11 +226,16 @@ Protected-branch synchronization pushes to GitLab with `ci.skip`, so these skip

Current skip-oriented patterns include:

- `.github/**/*`
- `*.md`
- `docs/**/*`
- `result_server/**/*`
- `config/system_info.csv`

> Synchronization note: this list mirrors the `paths:` entries in
> `.gitlab-ci.yml`. Update this document in the same PR when those rules
> change.

`system_info.csv` is the public portal catalog. Every system listed there must also be registered in `system.csv` and reference a queue defined in `queue.csv`. The reverse is intentionally not required: private or development-only systems may exist in `system.csv` / `queue.csv` without being exposed in `system_info.csv`.

`system_info.csv` はportalでユーザーに見える公開catalogです。そこに載せたsystemは必ず `system.csv` に登録され、`queue.csv` に定義されたqueueを参照する必要があります。逆方向は必須ではありません。開発用・非公開用のsystemやqueueは、`system_info.csv` に公開せず `system.csv` / `queue.csv` にだけ存在してよいです。
Expand Down
4 changes: 2 additions & 2 deletions docs/cx/BENCHKIT_GAP_ANALYSIS.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Continuous estimation has now moved beyond a mere entry point: a common estimati
However, estimation is still not yet broadly deployed across multiple applications, and AI-driven optimization integration remains mostly at the integration-point stage.

As of the current repository survey, BenchKit has six benchmark applications with `build.sh`/`run.sh`, but only `qws` has an `estimate.sh`.
The result portal also already has a meaningful test base (`result_server/tests`: 27), and the repository now has a repo-local Python dependency manifest, a standard portal test entrypoint under `result_server/tests`, and a lightweight GitHub Actions verification path for portal-oriented changes.
The result portal also already has a meaningful test base (`result_server/tests`: 30 `test_*.py` modules), and the repository now has a repo-local Python dependency manifest, a standard portal test entrypoint under `result_server/tests`, and a lightweight GitHub Actions verification path for portal-oriented changes.
The main GitLab pipeline still intentionally skips heavy benchmark execution when a direct or manually triggered GitLab pipeline sees changes limited to `result_server/**/*` or portal display metadata such as `config/system_info.csv`. Protected-branch synchronization itself uses `ci.skip`, so the dedicated lightweight GitHub Actions path should continue to be kept in sync as portal-side files evolve.

## 2.1 現時点で明示しておく設計負債 / Explicit Design Debts to Keep Visible
Expand Down Expand Up @@ -296,7 +296,7 @@ Once the estimation specification is clarified, many other design decisions beco

今回のコードベース調査では、性能推定に次ぐ実務上の詰まりどころとして、`result_server` の検証導線が見えた。

- `result_server/tests` には 27 本の pytest ベースのテストがあり、portal 側はすでに「検証すべき対象」になっている
- `result_server/tests` には 30 個の `test_*.py` モジュールがあり、portal 側はすでに「検証すべき対象」になっている
- repo-local な依存関係定義として `requirements-result-server.txt` があり、`result_server/tests/run_result_server_tests.py` が標準 test entrypoint として使える
- portal-oriented 変更向けの lightweight GitHub Actions として `.github/workflows/result-server-tests.yml` が用意されている
- `.gitlab-ci.yml` は直接または手動起動されたGitLab pipelineで `result_server/**/*` や `config/system_info.csv` 変更時に重い benchmark pipeline を skip する。保護ブランチ同期自体は `ci.skip` を使うため、GitHub Actions 側の path filter を portal 周辺の実ファイルに追従させ続ける必要がある
Expand Down
50 changes: 50 additions & 0 deletions docs/deploy/key-management.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Result Portal Key Management

This guide covers the secrets used by `result_server/app.py`.

## Required Secrets

Production deployments must provide:

- `FLASK_SECRET_KEY`: at least 32 characters, generated randomly.
- `RESULT_SERVER_KEYS`: one or more runner-scoped ingest keys.

Use runner-scoped keys instead of the legacy `RESULT_SERVER_KEY`:

```text
RESULT_SERVER_KEYS=runner-a:<RUNNER_A_KEY>,runner-b:<RUNNER_B_KEY>
```

Each key must be at least 32 characters and must not use known insecure
examples such as `dev-api-key`, `changeme`, or `secret`. The production app
refuses to start when these checks fail.

## Generation

Generate random values with a local secret generator, for example:

```bash
openssl rand -hex 32
```

Do not commit generated values. Store them in the deployment secret mechanism,
such as a systemd `EnvironmentFile`, a site secret manager, or an internal
vault service.

## Rotation

For a normal runner key rotation:

1. Add the new key to `RESULT_SERVER_KEYS` while keeping the old key.
2. Deploy the portal configuration.
3. Update the runner to use the new key.
4. Confirm successful ingest events for the runner.
5. Remove the old key after the agreed overlap window.

If a key may have leaked, remove it immediately, deploy the portal, update the
affected runner, and review ingest logs for suspicious activity.

## Logging

Logs may include runner ids and endpoint names. They must not include API key
values, TOTP codes, or Flask secret values.
4 changes: 3 additions & 1 deletion docs/guides/developer-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,8 +215,10 @@ For production portal deployments:

- Set `FLASK_SECRET_KEY` to a strong secret and run `result_server/app.py`, not `app_dev.py`.
- `app.py` binds to `127.0.0.1:8800` by default; set `RESULT_SERVER_HOST` and `RESULT_SERVER_PORT` explicitly when the deployment requires a different bind address.
- Set runner-scoped ingest keys with `RESULT_SERVER_KEYS=runner-a:key-a,runner-b:key-b`.
- Set runner-scoped ingest keys with `RESULT_SERVER_KEYS=runner-a:<RUNNER_A_KEY>,runner-b:<RUNNER_B_KEY>`.
- `FLASK_SECRET_KEY` and each ingest key must be at least 32 characters and must not use known insecure examples such as `dev-api-key`, `changeme`, or `secret`; production startup refuses these values.
- The legacy `RESULT_SERVER_KEY` variable is still accepted as runner `default` for compatibility, but should be rotated to `RESULT_SERVER_KEYS`.
- See `docs/deploy/key-management.md` for generation and rotation guidance.
- `REDIS_URL` must point to a monitored Redis instance; production authentication refuses login when Redis is unavailable.
- `app_dev.py` is localhost-only, uses ephemeral development secrets when none are provided, and enables the Werkzeug debugger only with `RESULT_SERVER_DEV_DEBUG=1`.

Expand Down
9 changes: 7 additions & 2 deletions result_server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,15 @@
from routes.results import results_bp
from utils.auth import parse_ingest_keys
from utils.csrf import init_csrf
from utils.preflight import validate_production_config


INGEST_KEYS = parse_ingest_keys()
PREFLIGHT_ERRORS = validate_production_config(os.environ, INGEST_KEYS)

if not INGEST_KEYS:
print("ERROR: RESULT_SERVER_KEYS or RESULT_SERVER_KEY is not set.", file=sys.stderr)
if PREFLIGHT_ERRORS:
for error in PREFLIGHT_ERRORS:
print(f"ERROR: {error}", file=sys.stderr)
sys.exit(1)


Expand Down Expand Up @@ -76,7 +79,9 @@ def _register_portal_blueprints(app, prefix):
"""Register all portal blueprints using the given URL prefix."""
from routes.admin import admin_bp
from routes.auth import auth_bp
from routes.security_metadata import register_security_metadata_routes

register_security_metadata_routes(app, prefix=prefix)
app.register_blueprint(api_bp, url_prefix=prefix)
app.register_blueprint(results_bp, url_prefix=f"{prefix}/results")
app.register_blueprint(estimated_bp, url_prefix=f"{prefix}/estimated")
Expand Down
2 changes: 2 additions & 0 deletions result_server/app_dev.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ def create_dev_app(base_dir):
from flask_session import Session

from routes.home import register_home_routes
from routes.security_metadata import register_security_metadata_routes
from utils.auth import parse_ingest_keys
from utils.csrf import init_csrf
from utils.system_info import get_all_systems_info, summarize_systems_info
Expand Down Expand Up @@ -197,6 +198,7 @@ def create_dev_app(base_dir):

# Home routes and loaders pull everything from current_app.config.
register_home_routes(app)
register_security_metadata_routes(app)

# Register all portal blueprints.
from routes.api import api_bp
Expand Down
47 changes: 39 additions & 8 deletions result_server/routes/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from utils.auth import verify_ingest_key

api_bp = Blueprint("api", __name__)
_TIMESTAMP_RE = re.compile(r"^\d{8}_\d{6}$")


# ==========================================
Expand All @@ -38,9 +39,12 @@ def require_api_key():

def save_json_file(data, prefix, out_dir, given_uuid=None):
"""Persist a JSON payload using atomic file replacement."""
if given_uuid is not None and not is_valid_uuid(given_uuid):
abort(400, description="Invalid UUID")

timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
unique_id = given_uuid or str(uuid.uuid4())
filename = f"{prefix}_{timestamp}_{unique_id}.json"
filename = _safe_basename(f"{prefix}_{timestamp}_{unique_id}.json")
path = os.path.join(out_dir, filename)
tmp_path = path + ".tmp"

Expand Down Expand Up @@ -85,13 +89,35 @@ def save_json_file(data, prefix, out_dir, given_uuid=None):

def is_valid_uuid(value):
"""Return whether the given string is a valid UUID."""
if not isinstance(value, str) or not value:
return False
try:
uuid.UUID(value)
return True
parsed = uuid.UUID(value)
return str(parsed) == value.lower()
except ValueError:
return False


def is_valid_timestamp(value):
"""Return whether the string matches the canonical timestamp format."""
return isinstance(value, str) and bool(_TIMESTAMP_RE.fullmatch(value))


def _safe_basename(name):
"""Return a basename-only file component or abort with 400."""
if not isinstance(name, str) or not name:
abort(400, description="Invalid file component")
if (
name in {".", ".."}
or os.path.isabs(name)
or os.path.basename(name) != name
or "/" in name
or "\\" in name
):
abort(400, description="Invalid file component")
return name


def _load_json_by_uuid(directory, field_path, uuid_value):
"""Return the first JSON payload whose target field matches the UUID."""
json_files = sorted(
Expand Down Expand Up @@ -222,12 +248,16 @@ def ingest_result():
def ingest_estimate():
"""Receive and persist an estimated-result JSON payload."""
require_api_key()
raw_uuid = request.headers.get("X-UUID")
if raw_uuid is not None and not is_valid_uuid(raw_uuid):
abort(400, description="Invalid X-UUID header")

data = request.data
return save_json_file(
data=data,
prefix="estimate",
out_dir=current_app.config["ESTIMATED_DIR"],
given_uuid=request.headers.get("X-UUID"),
given_uuid=raw_uuid,
), 200


Expand All @@ -241,8 +271,8 @@ def ingest_padata():
abort(400, description="Invalid or missing UUID")

timestamp = request.form.get("timestamp")
if not timestamp:
abort(400, description="Missing Timestamp")
if not is_valid_timestamp(timestamp):
abort(400, description="Invalid or missing Timestamp")

uploaded_file = request.files.get("file")
if not uploaded_file:
Expand All @@ -256,12 +286,13 @@ def ingest_padata():
]

if matched_files:
old_file_path = os.path.join(received_dir, matched_files[0])
old_file_path = os.path.join(received_dir, _safe_basename(matched_files[0]))
backup_path = old_file_path + ".bak"
shutil.move(old_file_path, backup_path)
save_path = old_file_path
else:
save_path = os.path.join(received_dir, f"padata_{timestamp}_{uuid_str}.tgz")
filename = _safe_basename(f"padata_{timestamp}_{uuid_str}.tgz")
save_path = os.path.join(received_dir, filename)

tmp_path = save_path + ".tmp"
with open(tmp_path, "wb") as f:
Expand Down
38 changes: 38 additions & 0 deletions result_server/routes/security_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Security metadata routes for vulnerability reporting and crawler hints."""

from flask import Response


SECURITY_TXT = """Contact: https://github.com/RIKEN-RCCS/benchkit/security/advisories/new
Expires: 2027-05-19T00:00:00Z
Preferred-Languages: ja, en
Canonical: https://fncx.r-ccs.riken.jp/.well-known/security.txt
Policy: https://github.com/RIKEN-RCCS/benchkit/blob/main/SECURITY.md
"""

ROBOTS_TXT = """User-agent: *
Disallow: /admin/
Disallow: /auth/
Disallow: /dev/admin/
Disallow: /dev/auth/
"""


def register_security_metadata_routes(app, prefix=""):
"""Register RFC 9116 security.txt and robots.txt routes."""
endpoint_prefix = (
"security_metadata"
if not prefix
else f"security_metadata_{prefix.strip('/').replace('/', '_')}"
)

@app.route(
f"{prefix}/.well-known/security.txt",
endpoint=f"{endpoint_prefix}_security_txt",
)
def security_txt():
return Response(SECURITY_TXT, mimetype="text/plain")

@app.route(f"{prefix}/robots.txt", endpoint=f"{endpoint_prefix}_robots_txt")
def robots_txt():
return Response(ROBOTS_TXT, mimetype="text/plain")
Loading
Loading