Compare commits

...
Sign in to create a new pull request.

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-interval 5s
--health-timeout 5s --health-timeout 5s
--health-retries 5 --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: env:
CI: "true" CI: "true"
WG_DATABASE_URL: postgresql+asyncpg://wiregui:wiregui@postgres/wiregui WG_DATABASE_URL: postgresql+asyncpg://wiregui:wiregui@postgres/wiregui
WG_REDIS_URL: redis://valkey:6379/0
MOCK_OIDC_HOST: mock-oidc
steps: steps:
- name: Install system dependencies - name: Install system dependencies
run: | run: |
@ -56,17 +93,13 @@ jobs:
needs: test needs: test
if: github.ref == 'refs/heads/main' && github.event_name == 'push' if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: permissions:
image: node:20-slim contents: write
outputs: outputs:
new_tag: ${{ steps.version.outputs.new_tag }} new_tag: ${{ steps.version.outputs.new_tag }}
new_version: ${{ steps.version.outputs.new_version }} new_version: ${{ steps.version.outputs.new_version }}
skip: ${{ steps.version.outputs.skip }} skip: ${{ steps.version.outputs.skip }}
steps: steps:
- name: Install dependencies
run: |
apt-get update && apt-get install -y --no-install-recommends bash git python3 ca-certificates
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with: 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", "cryptography>=44",
# Auth # Auth
"bcrypt>=4.0", "bcrypt>=4.0",
"python-jose[cryptography]>=3.3", "pyjwt[crypto]>=2.9",
"authlib>=1.4", "authlib>=1.4",
"pyotp>=2.9", "pyotp>=2.9",
"webauthn>=2.2", "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): async def test_seed_invalid_yaml(clean_config, monkeypatch):
path = Path(tempfile.mktemp(suffix=".yaml")) f = tempfile.NamedTemporaryFile(suffix=".yaml", delete=False, mode="w")
path.write_text(": : : invalid yaml [[[") 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)})()) monkeypatch.setattr("wiregui.auth.seed.get_settings", lambda: type("S", (), {"idp_config_file": str(path)})())
await seed_idp_providers() await seed_idp_providers()
async with async_session() as session: async with async_session() as session:

View file

@ -1,8 +1,6 @@
"""Tests for REST API endpoints and token auth.""" """Tests for REST API endpoints and token auth."""
import hashlib from wiregui.auth.api_token import _token_hmac, generate_api_token, resolve_bearer_token
from wiregui.auth.api_token import generate_api_token, resolve_bearer_token
from wiregui.auth.passwords import hash_password from wiregui.auth.passwords import hash_password
from wiregui.models.api_token import ApiToken from wiregui.models.api_token import ApiToken
from wiregui.models.user import User from wiregui.models.user import User
@ -15,7 +13,7 @@ from wiregui.utils.time import utcnow
def test_generate_api_token(): def test_generate_api_token():
plaintext, token_hash = generate_api_token() plaintext, token_hash = generate_api_token()
assert len(plaintext) > 20 assert len(plaintext) > 20
assert token_hash == hashlib.sha256(plaintext.encode()).hexdigest() assert token_hash == _token_hmac(plaintext)
def test_generate_api_token_unique(): 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" }, { 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]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.135.2" 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" }, { 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]] [[package]]
name = "pyopenssl" name = "pyopenssl"
version = "26.0.0" 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" }, { 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]] [[package]]
name = "python-multipart" name = "python-multipart"
version = "0.0.22" 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" }, { 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]] [[package]]
name = "shellingham" name = "shellingham"
version = "1.5.4" 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" }, { 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]] [[package]]
name = "smmap" name = "smmap"
version = "5.0.3" version = "5.0.3"
@ -2133,8 +2095,8 @@ dependencies = [
{ name = "loguru" }, { name = "loguru" },
{ name = "nicegui" }, { name = "nicegui" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "pyotp" }, { name = "pyotp" },
{ name = "python-jose", extra = ["cryptography"] },
{ name = "python3-saml" }, { name = "python3-saml" },
{ name = "pyyaml" }, { name = "pyyaml" },
{ name = "qrcode", extra = ["pil"] }, { name = "qrcode", extra = ["pil"] },
@ -2165,8 +2127,8 @@ requires-dist = [
{ name = "loguru", specifier = ">=0.7.3" }, { name = "loguru", specifier = ">=0.7.3" },
{ name = "nicegui", specifier = ">=2.12" }, { name = "nicegui", specifier = ">=2.12" },
{ name = "pydantic-settings", specifier = ">=2.7" }, { name = "pydantic-settings", specifier = ">=2.7" },
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.9" },
{ name = "pyotp", specifier = ">=2.9" }, { name = "pyotp", specifier = ">=2.9" },
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.3" },
{ name = "python3-saml", specifier = ">=1.16" }, { name = "python3-saml", specifier = ">=1.16" },
{ name = "pyyaml", specifier = ">=6.0" }, { name = "pyyaml", specifier = ">=6.0" },
{ name = "qrcode", extras = ["pil"], specifier = ">=8.0" }, { name = "qrcode", extras = ["pil"], specifier = ">=8.0" },

View file

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

View file

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

View file

@ -92,8 +92,8 @@ def _start_collector() -> None:
global _collector_proc global _collector_proc
_collector_proc = subprocess.Popen( _collector_proc = subprocess.Popen(
[sys.executable, "-m", "wiregui.collector"], [sys.executable, "-m", "wiregui.collector"],
stdout=subprocess.PIPE, stdout=subprocess.DEVNULL,
stderr=subprocess.STDOUT, stderr=subprocess.DEVNULL,
) )
logger.info("Metrics collector started (pid={})", _collector_proc.pid) 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") id_token = token.get("id_token")
if id_token: if id_token:
try: try:
from jose import jwt as jose_jwt import jwt as pyjwt
# Decode without verification — we already verified during token exchange # 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 {} userinfo = userinfo or {}
if not userinfo.get("email"): if not userinfo.get("email"):
userinfo["email"] = claims.get("email") userinfo["email"] = claims.get("email")