Skip to content

Commit fb7ac08

Browse files
committed
fix(security): harden Dockerfile (#71) + audit credential-logger calls (#72) + annotate JWT shape-detect (#73)
Batch of three findings from the 2026-05-04 owner-self code audit. #71 (HIGH) — Dockerfile ran as root with no HEALTHCHECK (trivy DS002 + semgrep dockerfile.security.missing-user). Added a dedicated UID 10001 user (primary group root for arbitrary-UID platform compatibility), chown the app dir, switch USER, and add a HEALTHCHECK against the real endpoint (port 8080, /health). Healthcheck uses a stdlib urllib one-liner instead of curl to avoid adding a package to the slim base image (no new deps). Dockerfile.test left as root by design: it only runs pytest (editable install + writes caches/artifacts under /app), so a non-root switch adds risk with no production-surface benefit. #72 (MEDIUM) — semgrep python-logger-credential-disclosure flagged 6 logger calls (credentials.py:66,88,104,129,145 + blockchain.py:96). Reviewed each: all log only credential IDs and service-generated error strings — never the credential body, signature, or key. No real leak. Added inline nosemgrep with a per-line rationale stating what is logged. #73 (LOW) — annotated the two intentional unverified jwt.decode calls in auth/token_parser.py (token-shape detection + claim extraction for identity bridging; real EdDSA signature verification happens in services/delegation_service.py and auth/crypto.py) with nosemgrep + explanatory comments. Renamed synthetic test fixtures that tripped gitleaks generic-api-key (test_identity.py, test_advanced_personas.py, simulate_users.py) to obvious sk-FAKE-fixture-* names; preserved the masking assertions in test_identity.py. Closes #71 Closes #72 Closes #73
1 parent 0f02ecd commit fb7ac08

7 files changed

Lines changed: 30 additions & 7 deletions

File tree

Dockerfile

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,15 @@ COPY . .
2727
ENV PORT=8080
2828
EXPOSE 8080
2929

30+
# Drop root: run as a dedicated unprivileged user (UID 10001, primary group root
31+
# so the image stays compatible with arbitrary-UID platforms like OpenShift).
32+
# Fixes trivy DS002 + semgrep dockerfile.security.missing-user.missing-user (#71).
33+
RUN useradd -r -u 10001 -g root attestix && chown -R attestix:root /app
34+
USER attestix
35+
36+
# Liveness probe via stdlib urllib (no curl in the slim base image, no new deps).
37+
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
38+
CMD python -c "import urllib.request,sys; sys.exit(0) if urllib.request.urlopen('http://localhost:8080/health').status == 200 else sys.exit(1)" || exit 1
39+
3040
# Run the API server
3141
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8080"]

attestix/api/routers/blockchain.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ def anchor_credential(
9393
artifact_id=body.credential_id,
9494
)
9595
if isinstance(result, dict) and "error" in result:
96+
# nosemgrep: python.lang.security.audit.logging.logger-credential-leak.python-logger-credential-disclosure -- logs credential_id + service error string only; no credential body/signature/key
9697
logger.warning(
9798
"Credential anchor failed for %s: %s",
9899
body.credential_id, result["error"],

attestix/api/routers/credentials.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ def issue_credential(
6363
expiry_days=body.expiry_days,
6464
)
6565
if isinstance(result, dict) and "error" in result:
66+
# nosemgrep: python.lang.security.audit.logging.logger-credential-leak.python-logger-credential-disclosure -- logs the service error string only; no credential body/signature/key
6667
logger.warning("Credential issuance failed: %s", result["error"])
6768
raise HTTPException(status_code=400, detail="Credential issuance failed")
6869
return result
@@ -85,6 +86,7 @@ def list_credentials(
8586
)
8687
# Service returns list with error dict on failure
8788
if results and isinstance(results[0], dict) and "error" in results[0]:
89+
# nosemgrep: python.lang.security.audit.logging.logger-credential-leak.python-logger-credential-disclosure -- logs the service error string only; no credential body/signature/key
8890
logger.error("Credential listing failed: %s", results[0]["error"])
8991
raise HTTPException(status_code=500, detail="Credential listing failed")
9092
return results
@@ -101,6 +103,7 @@ def verify_credential_external(
101103
"""
102104
result = svc.verify_credential_external(body.credential)
103105
if isinstance(result, dict) and "error" in result:
106+
# nosemgrep: python.lang.security.audit.logging.logger-credential-leak.python-logger-credential-disclosure -- logs the service error string only; no credential body/signature/key
104107
logger.warning("External credential verification failed: %s", result["error"])
105108
raise HTTPException(status_code=400, detail="Credential verification failed")
106109
return result
@@ -126,6 +129,7 @@ def verify_credential(
126129
"""Verify a credential by ID: check signature, expiry, and revocation."""
127130
result = svc.verify_credential(credential_id)
128131
if isinstance(result, dict) and "error" in result:
132+
# nosemgrep: python.lang.security.audit.logging.logger-credential-leak.python-logger-credential-disclosure -- logs credential_id + service error string only; no credential body/signature/key
129133
logger.warning("Credential verification failed for %s: %s", credential_id, result["error"])
130134
raise HTTPException(status_code=400, detail="Credential verification failed")
131135
return result
@@ -142,6 +146,7 @@ def revoke_credential(
142146
result = svc.revoke_credential(credential_id, reason=reason)
143147
if isinstance(result, dict) and "error" in result:
144148
error_msg = result["error"]
149+
# nosemgrep: python.lang.security.audit.logging.logger-credential-leak.python-logger-credential-disclosure -- logs credential_id + service error string only; no credential body/signature/key
145150
logger.warning("Credential revocation failed for %s: %s", credential_id, error_msg)
146151
if "not found" in error_msg.lower():
147152
raise HTTPException(status_code=404, detail="Credential not found")

attestix/auth/token_parser.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,10 @@ def detect_token_type(token: str) -> TokenType:
3939
if JWT_PATTERN.match(token):
4040
# Verify it's actually parseable as JWT
4141
try:
42-
jwt.decode(token, options={"verify_signature": False})
42+
# Token-shape detection only; no auth decision is made on this decode.
43+
# Real signature verification happens in attestix/services/delegation_service.py
44+
# (jwt.decode with EdDSA + server public key) and attestix/auth/crypto.py.
45+
jwt.decode(token, options={"verify_signature": False}) # nosemgrep: python.jwt.security.unverified-jwt-decode.unverified-jwt-decode
4346
return TokenType.JWT
4447
except jwt.DecodeError:
4548
pass
@@ -59,10 +62,13 @@ def parse_jwt_claims(token: str) -> Optional[dict]:
5962
Returns None if the token is not a valid JWT.
6063
"""
6164
try:
65+
# Claim extraction for identity bridging only; no auth decision is made here.
66+
# Real signature verification happens in attestix/services/delegation_service.py
67+
# (jwt.decode with EdDSA + server public key) and attestix/auth/crypto.py.
6268
claims = jwt.decode(
6369
token,
6470
options={
65-
"verify_signature": False,
71+
"verify_signature": False, # nosemgrep: python.jwt.security.unverified-jwt-decode.unverified-jwt-decode
6672
"verify_exp": False,
6773
"verify_aud": False,
6874
},

simulate_users.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1002,7 +1002,7 @@ def sim_enterprise_architect():
10021002
api_agent = call("create_agent_identity",
10031003
display_name="Partner-API-Agent",
10041004
source_protocol="api_key",
1005-
identity_token="sk-partner-integration-key-abc123",
1005+
identity_token="sk-FAKE-fixture-partner-integration", # synthetic fixture, not a real key
10061006
capabilities="data_exchange",
10071007
issuer_name="PartnerCo")
10081008
did_agent = call("create_agent_identity",

tests/e2e/test_advanced_personas.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -806,7 +806,7 @@ def test_multi_system_integration_workflow(self):
806806
"create_agent_identity",
807807
display_name="External-API-Agent",
808808
source_protocol="api_key",
809-
identity_token="sk-enterprise-abc123-partner",
809+
identity_token="sk-FAKE-fixture-enterprise-partner", # synthetic test fixture, not a real key
810810
capabilities="partner_api,data_exchange",
811811
description="Partner integration agent",
812812
issuer_name="PartnerCo",

tests/unit/test_identity.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,15 @@ def test_creates_with_all_fields(self, identity_service):
3131
assert result["issuer"]["name"] == "TestIssuer"
3232

3333
def test_masks_identity_token(self, identity_service):
34+
# Obviously-synthetic fixture: avoids gitleaks generic-api-key false positive.
3435
result = identity_service.create_identity(
3536
display_name="TokenBot",
3637
source_protocol="api_key",
37-
identity_token="sk-very-secret-api-key-1234567890",
38+
identity_token="sk-FAKE-fixture-do-not-use-7890",
3839
)
3940
token = result["identity_token"]
40-
assert "very-secret" not in token
41-
assert token.startswith("sk-v")
41+
assert "fixture-do-not-use" not in token
42+
assert token.startswith("sk-F")
4243
assert token.endswith("7890")
4344

4445

0 commit comments

Comments
 (0)