Compare commits

..

10 commits
main ... dev

Author SHA1 Message Date
Stefano Bertelli
2f63f4cd17 Merge branch 'main' into dev
All checks were successful
Dev / test (push) Successful in 1m22s
Dev / release (push) Successful in 37s
Dev / docker (push) Has been skipped
2026-05-09 11:46:19 -05:00
cca49ca2cf fix: prevent collector subprocess from deadlocking on full pipe buffer
All checks were successful
Dev / test (push) Successful in 1m13s
Dev / release (push) Successful in 41s
Dev / docker (push) Has been skipped
Collector was spawned with stdout=PIPE but nobody read from the pipe.
After days of accumulated log output the OS buffer filled, blocking
the collector and freezing all metrics updates.
2026-04-07 17:48:36 -05:00
Stefano Bertelli
31b31b7946 ci: exclude weak-sensitive-data-hashing rule from CodeQL
API token hashing uses HMAC-SHA256 on high-entropy tokens (256-bit
random), not passwords. Actual password hashing uses bcrypt.
2026-04-03 00:55:01 -05:00
Stefano Bertelli
604446f8ca fix: use HMAC-SHA256 with secret key for API token hashing 2026-04-03 00:51:38 -05:00
Stefano Bertelli
496334137d fix: replace python-jose with PyJWT to eliminate vulnerable ecdsa dependency 2026-04-03 00:46:36 -05:00
Stefano Bertelli
5c02598a46 fix: address CodeQL findings — sha512 for token hashing, secure tempfile 2026-04-03 00:41:16 -05:00
Stefano Bertelli
aa38c3797e ci: add security policy, CodeQL scanning, enable Dependabot 2026-04-03 00:35:42 -05:00
Stefano Bertelli
87989b899d fix(ci): add contents:write permission for release job to push tags 2026-04-03 00:03:44 -05:00
Stefano Bertelli
bde7a82224 fix(ci): remove container from release job, use ubuntu-latest directly 2026-04-02 23:58:22 -05:00
Stefano Bertelli
aaddb319bc fix(ci): add valkey, mock-oidc services and MOCK_OIDC_HOST env for e2e tests 2026-04-02 23:52:01 -05:00
12 changed files with 158 additions and 77 deletions

9
.github/codeql/codeql-config.yml vendored Normal file
View file

@ -0,0 +1,9 @@
name: "WireGUI CodeQL config"
query-filters:
# API token hashing uses HMAC-SHA256 which is appropriate for high-entropy
# tokens (256-bit random). Actual password hashing uses bcrypt.
# CodeQL flags any SHA-family hash as "weak for password hashing" but this
# rule is not applicable to API token lookups.
- exclude:
id: py/weak-sensitive-data-hashing

32
.github/workflows/codeql.yml vendored Normal file
View file

@ -0,0 +1,32 @@
name: CodeQL
on:
push:
branches:
- main
- dev
pull_request:
branches:
- main
schedule:
- cron: "0 6 * * 1"
jobs:
analyze:
name: Analyze (Python)
runs-on: ubuntu-latest
permissions:
security-events: write
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: python
config-file: .github/codeql/codeql-config.yml
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3

View file

@ -23,9 +23,46 @@ jobs:
--health-interval 5s
--health-timeout 5s
--health-retries 5
valkey:
image: valkey/valkey:8
options: >-
--health-cmd "valkey-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 5
mock-oidc:
image: ghcr.io/navikt/mock-oauth2-server:2.1.10
ports:
- 9000:9000
env:
SERVER_PORT: "9000"
JSON_CONFIG: >
{
"interactiveLogin": true,
"httpServer": "NettyWrapper",
"tokenCallbacks": [
{
"issuerId": "test-idp",
"tokenExpiry": 3600,
"requestMappings": [
{
"requestParam": "scope",
"match": "*",
"claims": {
"sub": "$${claim:sub}",
"email": "$${claim:sub}@test.local",
"name": "Test User"
}
}
]
}
]
}
env:
CI: "true"
WG_DATABASE_URL: postgresql+asyncpg://wiregui:wiregui@postgres/wiregui
WG_REDIS_URL: redis://valkey:6379/0
MOCK_OIDC_HOST: mock-oidc
steps:
- name: Install system dependencies
run: |
@ -56,17 +93,13 @@ jobs:
needs: test
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
container:
image: node:20-slim
permissions:
contents: write
outputs:
new_tag: ${{ steps.version.outputs.new_tag }}
new_version: ${{ steps.version.outputs.new_version }}
skip: ${{ steps.version.outputs.skip }}
steps:
- name: Install dependencies
run: |
apt-get update && apt-get install -y --no-install-recommends bash git python3 ca-certificates
- name: Checkout
uses: actions/checkout@v4
with:

39
SECURITY.md Normal file
View file

@ -0,0 +1,39 @@
# Security Policy
## Supported Versions
| Version | Supported |
|---------|--------------------|
| latest | :white_check_mark: |
## Reporting a Vulnerability
If you discover a security vulnerability in WireGUI, please report it responsibly through **GitHub's private vulnerability reporting**:
1. Go to the [Security Advisories](https://github.com/bartei/wiregui/security/advisories) page
2. Click **"Report a vulnerability"**
3. Fill in the details of the vulnerability
Please **do not** open a public issue for security vulnerabilities.
## What to Expect
- You will receive an acknowledgment within **48 hours**
- We will provide a timeline for a fix within **7 days**
- Security patches will be released as soon as possible
## Scope
The following are in scope for security reports:
- Authentication and authorization bypasses
- SQL injection, XSS, CSRF, or other injection vulnerabilities
- WireGuard configuration issues that could expose private keys
- API token or session handling flaws
- Privilege escalation between user roles
## Out of Scope
- Denial of service (DoS) attacks
- Issues in third-party dependencies (report these upstream)
- Social engineering attacks

View file

@ -20,7 +20,7 @@ dependencies = [
"cryptography>=44",
# Auth
"bcrypt>=4.0",
"python-jose[cryptography]>=3.3",
"pyjwt[crypto]>=2.9",
"authlib>=1.4",
"pyotp>=2.9",
"webauthn>=2.2",

View file

@ -166,8 +166,10 @@ async def test_seed_preserves_providers_not_in_yaml(clean_config, monkeypatch):
async def test_seed_invalid_yaml(clean_config, monkeypatch):
path = Path(tempfile.mktemp(suffix=".yaml"))
path.write_text(": : : invalid yaml [[[")
f = tempfile.NamedTemporaryFile(suffix=".yaml", delete=False, mode="w")
f.write(": : : invalid yaml [[[")
f.close()
path = Path(f.name)
monkeypatch.setattr("wiregui.auth.seed.get_settings", lambda: type("S", (), {"idp_config_file": str(path)})())
await seed_idp_providers()
async with async_session() as session:

View file

@ -1,8 +1,6 @@
"""Tests for REST API endpoints and token auth."""
import hashlib
from wiregui.auth.api_token import generate_api_token, resolve_bearer_token
from wiregui.auth.api_token import _token_hmac, generate_api_token, resolve_bearer_token
from wiregui.auth.passwords import hash_password
from wiregui.models.api_token import ApiToken
from wiregui.models.user import User
@ -15,7 +13,7 @@ from wiregui.utils.time import utcnow
def test_generate_api_token():
plaintext, token_hash = generate_api_token()
assert len(plaintext) > 20
assert token_hash == hashlib.sha256(plaintext.encode()).hexdigest()
assert token_hash == _token_hmac(plaintext)
def test_generate_api_token_unique():

70
uv.lock generated
View file

@ -600,18 +600,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/91/e0d457ee03ec33d79ee2cd8d212debb1bc21dfb99728ae35efdb5832dc22/dotty_dict-1.3.1-py3-none-any.whl", hash = "sha256:5022d234d9922f13aa711b4950372a06a6d64cb6d6db9ba43d0ba133ebfce31f", size = 7014, upload-time = "2022-07-09T18:50:55.058Z" },
]
[[package]]
name = "ecdsa"
version = "0.19.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/25/ca/8de7744cb3bc966c85430ca2d0fcaeea872507c6a4cf6e007f7fe269ed9d/ecdsa-0.19.2.tar.gz", hash = "sha256:62635b0ac1ca2e027f82122b5b81cb706edc38cd91c63dda28e4f3455a2bf930", size = 202432, upload-time = "2026-03-26T09:58:17.675Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/79/119091c98e2bf49e24ed9f3ae69f816d715d2904aefa6a2baa039a2ba0b0/ecdsa-0.19.2-py2.py3-none-any.whl", hash = "sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399", size = 150818, upload-time = "2026-03-26T09:58:15.808Z" },
]
[[package]]
name = "fastapi"
version = "0.135.2"
@ -1502,6 +1490,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pyjwt"
version = "2.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" },
]
[package.optional-dependencies]
crypto = [
{ name = "cryptography" },
]
[[package]]
name = "pyopenssl"
version = "26.0.0"
@ -1599,25 +1601,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/34/bd/b0d440685fbcafee462bed793a74aea88541887c4c30556a55ac64914b8d/python_gitlab-6.5.0-py3-none-any.whl", hash = "sha256:494e1e8e5edd15286eaf7c286f3a06652688f1ee20a49e2a0218ddc5cc475e32", size = 144419, upload-time = "2025-10-17T21:40:01.233Z" },
]
[[package]]
name = "python-jose"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ecdsa" },
{ name = "pyasn1" },
{ name = "rsa" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" },
]
[package.optional-dependencies]
cryptography = [
{ name = "cryptography" },
]
[[package]]
name = "python-multipart"
version = "0.0.22"
@ -1797,18 +1780,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
]
[[package]]
name = "rsa"
version = "4.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
]
[[package]]
name = "shellingham"
version = "1.5.4"
@ -1830,15 +1801,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842, upload-time = "2024-10-10T22:39:29.645Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "smmap"
version = "5.0.3"
@ -2133,8 +2095,8 @@ dependencies = [
{ name = "loguru" },
{ name = "nicegui" },
{ name = "pydantic-settings" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "pyotp" },
{ name = "python-jose", extra = ["cryptography"] },
{ name = "python3-saml" },
{ name = "pyyaml" },
{ name = "qrcode", extra = ["pil"] },
@ -2165,8 +2127,8 @@ requires-dist = [
{ name = "loguru", specifier = ">=0.7.3" },
{ name = "nicegui", specifier = ">=2.12" },
{ name = "pydantic-settings", specifier = ">=2.7" },
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.9" },
{ name = "pyotp", specifier = ">=2.9" },
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.3" },
{ name = "python3-saml", specifier = ">=1.16" },
{ name = "pyyaml", specifier = ">=6.0" },
{ name = "qrcode", extras = ["pil"], specifier = ">=8.0" },

View file

@ -1,27 +1,33 @@
"""API token authentication — Bearer token via Authorization header."""
import hashlib
import hmac
import secrets
from loguru import logger
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select
from wiregui.config import get_settings
from wiregui.models.api_token import ApiToken
from wiregui.models.user import User
from wiregui.utils.time import utcnow
def _token_hmac(token: str) -> str:
"""Compute a keyed HMAC-SHA256 digest of an API token."""
key = get_settings().secret_key.encode()
return hmac.new(key, token.encode(), "sha256").hexdigest()
def generate_api_token() -> tuple[str, str]:
"""Generate a new API token. Returns (plaintext_token, token_hash)."""
plaintext = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(plaintext.encode()).hexdigest()
return plaintext, token_hash
return plaintext, _token_hmac(plaintext)
async def resolve_bearer_token(session: AsyncSession, token: str) -> User | None:
"""Look up a Bearer token and return the associated user, or None."""
token_hash = hashlib.sha256(token.encode()).hexdigest()
token_hash = _token_hmac(token)
result = await session.execute(
select(ApiToken).where(ApiToken.token_hash == token_hash)
)

View file

@ -1,6 +1,6 @@
from datetime import datetime, timedelta, timezone
from jose import JWTError, jwt
import jwt
from wiregui.config import get_settings
@ -22,5 +22,5 @@ def decode_access_token(token: str) -> dict | None:
"""Decode and validate a JWT. Returns the payload dict or None if invalid/expired."""
try:
return jwt.decode(token, get_settings().secret_key, algorithms=[ALGORITHM])
except JWTError:
except (jwt.InvalidTokenError, jwt.ExpiredSignatureError):
return None

View file

@ -92,8 +92,8 @@ def _start_collector() -> None:
global _collector_proc
_collector_proc = subprocess.Popen(
[sys.executable, "-m", "wiregui.collector"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
logger.info("Metrics collector started (pid={})", _collector_proc.pid)

View file

@ -57,9 +57,9 @@ async def oidc_callback(provider_id: str, request: Request):
id_token = token.get("id_token")
if id_token:
try:
from jose import jwt as jose_jwt
import jwt as pyjwt
# Decode without verification — we already verified during token exchange
claims = jose_jwt.get_unverified_claims(id_token)
claims = pyjwt.decode(id_token, options={"verify_signature": False})
userinfo = userinfo or {}
if not userinfo.get("email"):
userinfo["email"] = claims.get("email")