Skip to content

Commit ca4c12c

Browse files
Lukas Geigerclaude
andcommitted
fix(security): keep auth credentials out of tracked config.json
'config --set auth.value TOKEN' previously wrote the secret into the git-tracked config.json. Now: - core/config.py: load order is defaults -> config.json -> config.local.json (gitignored overlay) -> env vars APIPROBER_AUTH_VALUE / APIPROBER_AUTH_TYPE (highest precedence, never written to disk) - new set_config_value() routes secret keys (auth.value) to config.local.json and writes only the raw user file instead of the fully merged defaults (no more config drift in the tracked file) - cmd_config masks the secret on the console and recommends the env var; numeric type conversion now also handles negative integers - .gitignore: add config.local.json - README: new 'Credential Handling' section, project structure updated Adds regression tests (secret routing, overlay, env precedence, no-drift writes). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent b12a525 commit ca4c12c

5 files changed

Lines changed: 190 additions & 23 deletions

File tree

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@ _private/
3333
.venv/
3434
venv/
3535

36-
# Environment Variables
36+
# Environment Variables / local secrets
3737
.env
38+
config.local.json
3839

3940
# Logs
4041
*.log

README.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,23 @@ python api_prober.py config --set delay_ms 1000
122122
python api_prober.py config --set auth.type bearer
123123
```
124124

125+
### Credential Handling
126+
127+
Credentials are kept out of the tracked `config.json` and out of the local
128+
SQLite database:
129+
130+
- **Recommended:** set the environment variables `APIPROBER_AUTH_VALUE` (and
131+
optionally `APIPROBER_AUTH_TYPE`). They take precedence over all config
132+
files and are never written to disk.
133+
- `python api_prober.py config --set auth.value "TOKEN"` writes the value to
134+
`config.local.json` -- a gitignored overlay file next to `config.json` --
135+
never to the tracked `config.json`.
136+
- Config resolution order: defaults -> `config.json` -> `config.local.json`
137+
-> environment variables.
138+
- Probe run configurations stored in the SQLite database have `auth.value`
139+
redacted (`***REDACTED***`); `resume` re-reads the credential from the
140+
current config or environment.
141+
125142
---
126143

127144
## Discovery Strategies
@@ -152,7 +169,8 @@ ApiProber is designed for responsible API exploration:
152169
```
153170
ApiProber/
154171
+-- api_prober.py CLI entry point
155-
+-- config.json Default configuration
172+
+-- config.json Default configuration (no secrets)
173+
+-- config.local.json Local overrides incl. auth.value -- gitignored
156174
+-- core/ Core modules
157175
| +-- config.py Configuration management
158176
| +-- database.py SQLite persistence layer

api_prober.py

Lines changed: 19 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -185,11 +185,11 @@ def cmd_resume(args):
185185

186186
def cmd_config(args):
187187
"""Konfiguration anzeigen oder setzen."""
188-
from ApiProber.core.config import load_config, save_config
189-
190-
config = load_config()
188+
from ApiProber.core.config import (load_config, set_config_value,
189+
SECRET_KEYS, ENV_AUTH_VALUE)
191190

192191
if args.show:
192+
config = load_config()
193193
print(json.dumps(config, indent=4, ensure_ascii=False))
194194
return 0
195195

@@ -200,25 +200,24 @@ def cmd_config(args):
200200
# Typ-Konvertierung
201201
if value.lower() in ("true", "false"):
202202
value = value.lower() == "true"
203-
elif value.isdigit():
204-
value = int(value)
205-
else:
203+
elif key not in SECRET_KEYS:
206204
try:
207-
value = float(value)
205+
value = int(value)
208206
except ValueError:
209-
pass # bleibt String
210-
211-
# Verschachtelte Keys mit Punkt-Notation
212-
keys = key.split(".")
213-
target = config
214-
for k in keys[:-1]:
215-
if k not in target or not isinstance(target[k], dict):
216-
target[k] = {}
217-
target = target[k]
218-
target[keys[-1]] = value
219-
220-
save_config(config)
221-
print(f"Gesetzt: {key} = {value}")
207+
try:
208+
value = float(value)
209+
except ValueError:
210+
pass # bleibt String
211+
212+
target_path = set_config_value(key, value)
213+
214+
if key in SECRET_KEYS:
215+
# Secret nie auf der Konsole oder in der getrackten Datei zeigen
216+
print(f"Gesetzt: {key} = *** -> {target_path.name} (gitignored)")
217+
print(f"Hinweis: Empfohlen ist die Umgebungsvariable {ENV_AUTH_VALUE} --")
218+
print("sie hat Vorrang vor allen Config-Dateien und landet nie auf der Platte.")
219+
else:
220+
print(f"Gesetzt: {key} = {value} -> {target_path.name}")
222221
return 0
223222

224223
print("Verwendung: config --show | config --set KEY VALUE")

core/config.py

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
Pattern: llmauto/core/config.py (DEFAULT + load/save + deepcopy)
66
"""
77
import json
8+
import os
89
from pathlib import Path
910
from copy import deepcopy
1011

@@ -13,6 +14,16 @@
1314
# Placeholder stored instead of real credentials when a config is persisted
1415
REDACTED_PLACEHOLDER = "***REDACTED***"
1516

17+
# Gitignored overlay file for local values, especially credentials
18+
LOCAL_CONFIG_NAME = "config.local.json"
19+
20+
# Environment variables take precedence over both config files
21+
ENV_AUTH_VALUE = "APIPROBER_AUTH_VALUE"
22+
ENV_AUTH_TYPE = "APIPROBER_AUTH_TYPE"
23+
24+
# Keys that must never be written to the tracked config.json
25+
SECRET_KEYS = {"auth.value"}
26+
1627
DEFAULT_CONFIG = {
1728
"delay_ms": 500,
1829
"max_requests": 500,
@@ -51,17 +62,38 @@
5162

5263

5364
def load_config(config_path=None):
54-
"""Laedt Konfiguration aus JSON-Datei, merged mit Defaults."""
65+
"""Laedt Konfiguration aus JSON-Datei, merged mit Defaults.
66+
67+
Reihenfolge (spaetere Quellen ueberschreiben fruehere):
68+
1. DEFAULT_CONFIG
69+
2. config.json (getrackt, KEINE Secrets)
70+
3. config.local.json (gitignored Overlay, z.B. auth.value)
71+
4. Umgebungsvariablen APIPROBER_AUTH_VALUE / APIPROBER_AUTH_TYPE
72+
"""
5573
if config_path is None:
5674
config_path = BASE_DIR / "config.json"
5775
else:
5876
config_path = Path(config_path)
77+
local_path = config_path.parent / LOCAL_CONFIG_NAME
5978

6079
config = deepcopy(DEFAULT_CONFIG)
6180
if config_path.exists():
6281
with open(config_path, "r", encoding="utf-8") as f:
6382
user_config = json.load(f)
6483
_deep_merge(config, user_config)
84+
if local_path.exists():
85+
with open(local_path, "r", encoding="utf-8") as f:
86+
local_config = json.load(f)
87+
_deep_merge(config, local_config)
88+
89+
# Environment overrides (highest precedence, never touch any file)
90+
env_auth_type = os.environ.get(ENV_AUTH_TYPE)
91+
if env_auth_type:
92+
config.setdefault("auth", {})["type"] = env_auth_type
93+
env_auth_value = os.environ.get(ENV_AUTH_VALUE)
94+
if env_auth_value:
95+
config.setdefault("auth", {})["value"] = env_auth_value
96+
6597
return config
6698

6799

@@ -76,6 +108,46 @@ def save_config(config, config_path=None):
76108
json.dump(config, f, indent=4, ensure_ascii=False)
77109

78110

111+
def set_config_value(key, value, config_path=None):
112+
"""Setzt einen Konfigurationswert persistent (Punkt-Notation moeglich).
113+
114+
Secrets (SECRET_KEYS, z.B. auth.value) landen in der gitignorten
115+
config.local.json, alle anderen Werte in config.json. Es wird nur die
116+
jeweilige Roh-Datei geschrieben -- Defaults werden NICHT zurueckgeschrieben
117+
(kein Config-Drift in der getrackten Datei).
118+
119+
Returns:
120+
Path: Datei, in die geschrieben wurde.
121+
"""
122+
if config_path is None:
123+
config_path = BASE_DIR / "config.json"
124+
else:
125+
config_path = Path(config_path)
126+
127+
if key in SECRET_KEYS:
128+
target_path = config_path.parent / LOCAL_CONFIG_NAME
129+
else:
130+
target_path = config_path
131+
132+
raw = {}
133+
if target_path.exists():
134+
with open(target_path, "r", encoding="utf-8") as f:
135+
raw = json.load(f)
136+
137+
node = raw
138+
parts = key.split(".")
139+
for part in parts[:-1]:
140+
if part not in node or not isinstance(node[part], dict):
141+
node[part] = {}
142+
node = node[part]
143+
node[parts[-1]] = value
144+
145+
with open(target_path, "w", encoding="utf-8") as f:
146+
json.dump(raw, f, indent=4, ensure_ascii=False)
147+
f.write("\n")
148+
return target_path
149+
150+
79151
def get_db_path(config=None):
80152
"""Gibt absoluten Pfad zur DB zurueck."""
81153
if config is None:

test_smoke.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,83 @@ def test_resume_restores_auth_value_from_current_config(self, tmp_path, monkeypa
238238
assert orch.config["auth"]["type"] == "bearer"
239239

240240

241+
class TestSecretConfigStorage:
242+
"""Regressionstests: auth.value darf nie in die getrackte config.json."""
243+
244+
def _ensure_package_importable(self):
245+
parent_dir = str(API_PROBER_DIR.parent)
246+
if parent_dir not in sys.path:
247+
sys.path.insert(0, parent_dir)
248+
249+
def test_set_auth_value_goes_to_local_file(self, tmp_path):
250+
"""set_config_value('auth.value', ...) schreibt nach config.local.json."""
251+
self._ensure_package_importable()
252+
from ApiProber.core.config import set_config_value
253+
254+
cfg_path = tmp_path / "config.json"
255+
cfg_path.write_text('{"delay_ms": 100}\n', encoding="utf-8")
256+
257+
written = set_config_value("auth.value", "my-token", config_path=cfg_path)
258+
259+
assert written.name == "config.local.json"
260+
assert "my-token" not in cfg_path.read_text(encoding="utf-8"), \
261+
"Secret darf nicht in config.json landen"
262+
local = json.loads((tmp_path / "config.local.json").read_text(encoding="utf-8"))
263+
assert local["auth"]["value"] == "my-token"
264+
265+
def test_set_normal_value_goes_to_config_json(self, tmp_path):
266+
"""Nicht-Secrets gehen weiterhin in config.json, ohne Default-Drift."""
267+
self._ensure_package_importable()
268+
from ApiProber.core.config import set_config_value
269+
270+
cfg_path = tmp_path / "config.json"
271+
cfg_path.write_text('{"delay_ms": 100}\n', encoding="utf-8")
272+
273+
written = set_config_value("delay_ms", 250, config_path=cfg_path)
274+
275+
assert written == cfg_path
276+
raw = json.loads(cfg_path.read_text(encoding="utf-8"))
277+
assert raw == {"delay_ms": 250}, \
278+
"Nur Roh-Werte schreiben, keine gemergten Defaults (Config-Drift)"
279+
280+
def test_local_config_overlays_config_json(self, tmp_path, monkeypatch):
281+
"""config.local.json ueberlagert config.json beim Laden."""
282+
self._ensure_package_importable()
283+
from ApiProber.core.config import load_config
284+
285+
monkeypatch.delenv("APIPROBER_AUTH_VALUE", raising=False)
286+
monkeypatch.delenv("APIPROBER_AUTH_TYPE", raising=False)
287+
288+
cfg_path = tmp_path / "config.json"
289+
cfg_path.write_text('{"delay_ms": 100}\n', encoding="utf-8")
290+
(tmp_path / "config.local.json").write_text(
291+
'{"auth": {"type": "bearer", "value": "local-secret"}}\n',
292+
encoding="utf-8"
293+
)
294+
295+
config = load_config(cfg_path)
296+
assert config["delay_ms"] == 100
297+
assert config["auth"]["type"] == "bearer"
298+
assert config["auth"]["value"] == "local-secret"
299+
300+
def test_env_var_overrides_all_config_files(self, tmp_path, monkeypatch):
301+
"""APIPROBER_AUTH_VALUE hat Vorrang vor config.json und config.local.json."""
302+
self._ensure_package_importable()
303+
from ApiProber.core.config import load_config
304+
305+
cfg_path = tmp_path / "config.json"
306+
cfg_path.write_text('{}\n', encoding="utf-8")
307+
(tmp_path / "config.local.json").write_text(
308+
'{"auth": {"value": "file-secret"}}\n', encoding="utf-8"
309+
)
310+
monkeypatch.setenv("APIPROBER_AUTH_VALUE", "env-secret")
311+
monkeypatch.setenv("APIPROBER_AUTH_TYPE", "api_key")
312+
313+
config = load_config(cfg_path)
314+
assert config["auth"]["value"] == "env-secret"
315+
assert config["auth"]["type"] == "api_key"
316+
317+
241318
class TestRobotsServerError:
242319
"""Regressionstest: 5xx beim robots.txt-Abruf darf NICHT 'alles erlaubt' bedeuten."""
243320

0 commit comments

Comments
 (0)