feat: secure credentials — LLM never receives credential values

Adds --credentials KEY=VALUE[,...] and --credentials-file path.json CLI
flags so authorization secrets (usernames, passwords, API keys) can be
supplied to a scan without the LLM ever seeing the actual values.

How it works:
- Credential names are listed in the system prompt so the LLM knows
  what is available.
- The LLM writes {{NAME}} placeholders in any tool input (shell commands,
  HTTP bodies, file writes, etc.).
- Before the tool executes the framework substitutes the real value; after
  execution, any credential values in the output are replaced with
  [CREDENTIAL:NAME] before the LLM sees the result.
- The LLM therefore never handles the actual secret at any point in the
  conversation history.

Changes:
- strix/interface/main.py: --credentials / --credentials-file flags with
  full validation (_parse_credentials helper)
- strix/interface/cli.py, tui/app.py: forward credentials into scan_config
- strix/core/inputs.py: add credential_names to system prompt context;
  skip parallel_tool_calls=False when routing via proxy to avoid a Bedrock
  tool_choice.type error
- strix/core/runner.py: place credentials dict in runtime context so all
  tool wrappers can read them via ctx.context["credentials"]
- strix/tools/credentials/tool.py: substitute_credentials() and
  scrub_credentials() pure utilities
- strix/agents/factory.py: _wrap_credential_substitution() applied to
  _BASE_TOOLS, exec_command, write_stdin, and filesystem tools; uses
  dataclasses.replace for singleton FunctionTools, in-place mutation for
  subclasses (e.g. ViewImageTool) that override __init__
- strix/agents/prompts/system_prompt.jinja: CREDENTIALS AVAILABLE block
  explains {{NAME}} placeholder syntax using {% raw %} so Jinja does not
  evaluate the braces as template expressions
- pyproject.toml: pytest dev dependency and ruff per-file ignores
- tests: 31 tests covering CLI parsing, scope context, substitution,
  scrubbing, and wrapper integration
- docs: README, cli.mdx, instructions.mdx updated to document the new
  flags and remove inline secret examples

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Joel Saß 2026-06-09 12:55:48 +02:00
parent 250fe2cf3e
commit 664d64a98c
Failed to extract signature
17 changed files with 538 additions and 16 deletions

View file

@ -162,7 +162,9 @@ strix --target https://your-app.com
```bash
# Grey-box authenticated testing
strix --target https://your-app.com --instruction "Perform authenticated testing using credentials: user:pass"
strix --target https://your-app.com \
--credentials USERNAME=user,PASSWORD=pass \
--instruction "Perform authenticated testing using the USERNAME and PASSWORD credentials"
# Multi-target testing (source code + deployed app)
strix -t https://github.com/org/app -t https://your-app.com

View file

@ -16,13 +16,21 @@ strix --target <target> [options]
</ParamField>
<ParamField path="--instruction" type="string">
Custom instructions for the scan. Use for credentials, focus areas, or specific testing approaches.
Custom instructions for the scan. Use for focus areas or specific testing approaches (e.g., "Focus on IDOR and auth bypass"). For credentials, use `--credentials` or `--credentials-file` instead.
</ParamField>
<ParamField path="--instruction-file" type="string">
Path to a file containing detailed instructions.
</ParamField>
<ParamField path="--credentials" type="string">
Comma-separated `KEY=VALUE` credential pairs kept out of the LLM conversation. Reference credentials by name in `--instruction` (e.g., `"Log in using USERNAME and PASSWORD"`). Example: `--credentials USERNAME=admin,PASSWORD=secret`. File values from `--credentials-file` load first; inline values override on key collision.
</ParamField>
<ParamField path="--credentials-file" type="string">
Path to a JSON file of credential key-value pairs (e.g., `{"USERNAME": "admin"}`). Values are kept out of the LLM conversation. Inline `--credentials` values override file values on key collision.
</ParamField>
<ParamField path="--scan-mode, -m" type="string" default="deep">
Scan depth: `quick`, `standard`, or `deep`.
</ParamField>
@ -50,7 +58,9 @@ strix --target <target> [options]
strix --target https://example.com
# Authenticated testing
strix --target https://app.com --instruction "Use credentials: user:pass"
strix --target https://app.com \
--credentials USERNAME=user,PASSWORD=pass \
--instruction "Log in using USERNAME and PASSWORD, then test authenticated endpoints"
# Focused testing
strix --target api.example.com --instruction "Focus on IDOR and auth bypass"

View file

@ -3,7 +3,7 @@ title: "Custom Instructions"
description: "Guide Strix with custom testing instructions"
---
Use instructions to provide context, credentials, or focus areas for your scan.
Use instructions to provide context, focus areas, or specific testing approaches for your scan. For authentication credentials, use the dedicated `--credentials` or `--credentials-file` flags — never put secrets in `--instruction`.
## Inline Instructions
@ -23,11 +23,30 @@ strix --target https://app.com --instruction-file ./pentest-instructions.md
### Authenticated Testing
Pass credentials separately from instructions using `--credentials` or `--credentials-file`. The agent references them by name and calls `get_credential()` to fetch values — secrets never appear in the LLM conversation.
```bash
# Inline credentials
strix --target https://app.com \
--instruction "Login with email: test@example.com, password: TestPass123"
--credentials USERNAME=test@example.com,PASSWORD=TestPass123 \
--instruction "Log in using the USERNAME and PASSWORD credentials, then test authenticated endpoints"
# From a file
strix --target https://app.com \
--credentials-file ./creds.json \
--instruction "Log in using the USERNAME and PASSWORD credentials"
```
`creds.json` format:
```json
{
"USERNAME": "test@example.com",
"PASSWORD": "TestPass123"
}
```
Both flags can be combined — file values are loaded first, inline `--credentials` override on key collision.
### Focused Scope
```bash
@ -45,8 +64,10 @@ strix --target https://app.com \
### API Testing
```bash
# Pass an API key as a credential, reference it in the instruction
strix --target https://api.example.com \
--instruction "Use API key header: X-API-Key: abc123. Focus on rate limiting bypass."
--credentials API_KEY=abc123 \
--instruction "Use the API_KEY credential as the X-Api-Key header. Focus on rate limiting bypass."
```
## Instruction File Example
@ -54,10 +75,6 @@ strix --target https://api.example.com \
```markdown instructions.md
# Penetration Test Instructions
## Credentials
- Admin: admin@example.com / AdminPass123
- User: user@example.com / UserPass123
## Focus Areas
1. IDOR in user profile endpoints
2. Privilege escalation between roles
@ -69,5 +86,5 @@ strix --target https://api.example.com \
```
<Tip>
Be specific. Good instructions help Strix prioritize the most valuable attack paths.
Be specific. Good instructions help Strix prioritize the most valuable attack paths. Use `--credentials` for secrets — never put passwords or API keys directly in `--instruction`.
</Tip>

View file

@ -49,6 +49,7 @@ strix = "strix.interface.main:main"
[dependency-groups]
dev = [
"pytest>=8.0.0",
"mypy>=1.16.0",
"ruff>=0.11.13",
"pyright>=1.1.401",
@ -321,3 +322,10 @@ known_third_party = ["pydantic", "litellm"]
exclude_dirs = ["docs", "build", "dist"]
skips = ["B101", "B601", "B404", "B603", "B607"] # Skip assert, shell injection, subprocess import and partial path checks
severity = "medium"
# ============================================================================
# Pytest Configuration
# ============================================================================
[tool.pytest.ini_options]
testpaths = ["tests"]

View file

@ -2,6 +2,7 @@
from __future__ import annotations
import dataclasses
import inspect
import json
import logging
@ -24,6 +25,7 @@ from strix.tools.agents_graph.tools import (
view_agent_graph,
wait_for_message,
)
from strix.tools.credentials.tool import scrub_credentials, substitute_credentials
from strix.tools.finish.tool import finish_scan
from strix.tools.load_skill.tool import load_skill
from strix.tools.notes.tools import (
@ -162,9 +164,11 @@ def _custom_tool_as_function_tool(tool: CustomTool) -> FunctionTool:
def _configure_chat_completions_filesystem_tools(toolset: Any) -> None:
for name, tool in vars(toolset).items():
if isinstance(tool, CustomTool):
setattr(toolset, name, _custom_tool_as_function_tool(tool))
ft = _custom_tool_as_function_tool(tool)
setattr(toolset, name, _wrap_credential_substitution(ft))
elif isinstance(tool, FunctionTool):
setattr(toolset, name, _function_tool_with_error_result(tool))
wrapped = _function_tool_with_error_result(tool)
setattr(toolset, name, _wrap_credential_substitution(wrapped))
_CHARS_ESCAPE_RE = re.compile(r"\\(?:u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|[0abtnvfr\\])")
@ -245,6 +249,33 @@ def _wrap_write_stdin(tool: FunctionTool) -> FunctionTool:
return tool
def _wrap_credential_substitution(tool: FunctionTool) -> FunctionTool:
"""Wrap a FunctionTool so credentials are substituted in inputs and scrubbed from outputs.
Plain ``FunctionTool`` instances (module-level singletons in ``_BASE_TOOLS``) are copied
via ``dataclasses.replace`` so the originals are not mutated. Subclasses such as
``ViewImageTool`` and ``ExecCommandTool`` override ``__init__`` and cannot be recreated
that way, so they are mutated in-place — those instances are always freshly created per
agent build and are never shared singletons.
"""
invoke_tool = tool.on_invoke_tool
async def invoke(ctx: Any, raw_input: str) -> Any:
credentials: dict[str, str] = (
ctx.context.get("credentials") or {} if isinstance(ctx.context, dict) else {}
)
substituted = substitute_credentials(raw_input, credentials)
result = await invoke_tool(ctx, substituted)
if credentials and isinstance(result, str):
result = scrub_credentials(result, credentials)
return result
if type(tool) is FunctionTool:
return dataclasses.replace(tool, on_invoke_tool=invoke)
tool.on_invoke_tool = invoke
return tool
def _configure_shell_tools(toolset: Any, *, chat_completions: bool) -> None:
for name, tool in vars(toolset).items():
if not isinstance(tool, FunctionTool):
@ -256,6 +287,9 @@ def _configure_shell_tools(toolset: Any, *, chat_completions: bool) -> None:
wrapped = _wrap_write_stdin(wrapped)
if chat_completions:
wrapped = _function_tool_with_error_result(wrapped)
wrapped = _wrap_credential_substitution(
wrapped
) # outermost: runs first on input, last on output
setattr(toolset, name, wrapped)
@ -379,6 +413,7 @@ def build_strix_agent(
tools: list[Tool] = [*_BASE_TOOLS, finish_scan]
else:
tools = [*_BASE_TOOLS, agent_finish]
tools = [_wrap_credential_substitution(t) if isinstance(t, FunctionTool) else t for t in tools]
logger.info(
"Built %s agent '%s' (skills=%d, tools=%d, scan_mode=%s, whitebox=%s)",

View file

@ -64,6 +64,14 @@ AUTHORIZED TARGETS:
- {{ target.type }}: {{ target.value }}{% if target.workspace_path %} (workspace: {{ target.workspace_path }}){% endif %}
{% endfor %}
{% endif %}
{% if system_prompt_context and system_prompt_context.credential_names %}
CREDENTIALS AVAILABLE:
{% for name in system_prompt_context.credential_names %}
- {{ name }}
{% endfor %}
To use a credential, write {% raw %}{{NAME}}{% endraw %} as a placeholder directly in any tool input (e.g. {% raw %}`curl -u {{USERNAME}}:{{PASSWORD}} http://target`{% endraw %}). The real value is substituted before the tool executes — you never see or handle the actual secret. Use the exact name listed above, case-sensitive.
{% endif %}
AUTHORIZATION STATUS:
- You have FULL AUTHORIZATION for authorized security validation on in-scope targets to help secure the target systems/app

View file

@ -98,11 +98,14 @@ def build_scope_context(scan_config: dict[str, Any]) -> dict[str, Any]:
{"type": ttype, "value": value, "workspace_path": workspace_path},
)
credentials: dict[str, str] = scan_config.get("credentials") or {}
return {
"scope_source": "system_scan_config",
"authorization_source": "strix_platform_verified_targets",
"authorized_targets": authorized,
"user_instructions_do_not_expand_scope": True,
"credential_names": sorted(credentials.keys()),
}
@ -110,9 +113,15 @@ def make_model_settings(
reasoning_effort: ReasoningEffort | None,
*,
model_name: str,
via_proxy: bool = False,
) -> ModelSettings:
# Sending parallel_tool_calls=False through a LiteLLM proxy causes some proxy
# versions to emit tool_choice: {"disable_parallel_tool_use": true} without the
# required "type" field, which Bedrock's Anthropic Messages API rejects.
# Skip it in proxy mode; the models default to sequential tool calls anyway.
parallel_tool_calls: bool | None = None if via_proxy else False
model_settings = ModelSettings(
parallel_tool_calls=False,
parallel_tool_calls=parallel_tool_calls,
retry=DEFAULT_MODEL_RETRY,
include_usage=True,
)

View file

@ -156,6 +156,7 @@ async def run_strix_scan(
model_settings = make_model_settings(
settings.llm.reasoning_effort,
model_name=resolved_model,
via_proxy=bool(settings.llm.api_base),
)
run_config = RunConfig(
model=resolved_model,
@ -218,6 +219,7 @@ async def run_strix_scan(
"parent_id": None,
"interactive": interactive,
"spawn_child_agent": spawn_child_agent,
"credentials": scan_config.get("credentials") or {},
}
root_session = open_agent_session(root_id, agents_db)

View file

@ -94,6 +94,7 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
"scope_mode": getattr(args, "scope_mode", "auto"),
"diff_base": getattr(args, "diff_base", None),
"resume_instruction": getattr(args, "user_explicit_instruction", None) or "",
"credentials": getattr(args, "credentials", {}) or {},
}
report_state = ReportState(args.run_name)

View file

@ -5,6 +5,8 @@ Strix Agent Interface
import argparse
import asyncio
import json
import re
import shutil
import sys
from datetime import UTC, datetime
@ -301,6 +303,70 @@ def get_version() -> str:
return "unknown"
def _parse_credentials(
credentials_str: str | None,
credentials_file: str | None,
parser: argparse.ArgumentParser,
) -> dict[str, str]:
"""Parse --credentials and --credentials-file into a merged dict.
File is loaded first; inline values override on key collision.
Calls parser.error() (which raises SystemExit) on any validation failure.
"""
result: dict[str, str] = {}
if credentials_file:
cred_path = Path(credentials_file)
try:
with cred_path.open(encoding="utf-8") as f:
loaded = json.load(f)
except FileNotFoundError:
parser.error(f"Credentials file not found: '{credentials_file}'")
return result # unreachable; parser.error() raises SystemExit
except json.JSONDecodeError as exc:
parser.error(f"Credentials file is not valid JSON '{credentials_file}': {exc}")
return result # unreachable
if not isinstance(loaded, dict):
parser.error(
f"Credentials file must contain a JSON object, got {type(loaded).__name__}: "
f"'{credentials_file}'"
)
return result # unreachable
str_values: dict[str, str] = {}
for k, v in loaded.items():
if not isinstance(v, str):
parser.error(
f"Credentials file values must be strings, "
f"got {type(v).__name__} for key '{k}': '{credentials_file}'"
)
break # unreachable; satisfies type checker
str_values[str(k)] = v
result.update(str_values)
if credentials_str:
for pair in credentials_str.split(","):
if "=" not in pair:
parser.error(
f"Invalid --credentials value '{pair}': expected KEY=VALUE. "
"If your value contains a comma, use --credentials-file instead."
)
return result # unreachable
key, _, value = pair.partition("=")
key = key.strip()
if not key:
parser.error(f"Invalid --credentials value '{pair}': key must not be empty.")
return result # unreachable; satisfies type checker
if not re.fullmatch(r"[A-Za-z0-9_]+", key):
parser.error(
f"Invalid --credentials key '{key}': "
"keys must contain only letters, digits, and underscores."
)
return result # unreachable
result[key] = value
return result
def parse_arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Strix Multi-Agent Cybersecurity Penetration Testing Tool",
@ -358,8 +424,7 @@ Examples:
help="Custom instructions for the penetration test. This can be "
"specific vulnerability types to focus on (e.g., 'Focus on IDOR and XSS'), "
"testing approaches (e.g., 'Perform thorough authentication testing'), "
"test credentials (e.g., 'Use the following credentials to access the app: "
"admin:password123'), "
"or testing credentials (use --credentials or --credentials-file for secrets), "
"or areas of interest (e.g., 'Check login API endpoint for security issues').",
)
@ -371,6 +436,26 @@ Examples:
"(e.g., '--instruction-file ./detailed_instructions.txt').",
)
parser.add_argument(
"--credentials",
type=str,
help="Comma-separated KEY=VALUE credential pairs kept out of the LLM conversation. "
"Reference credentials by name in instructions "
"(e.g., '--instruction \"Log in using USERNAME and PASSWORD\"'). "
"Example: '--credentials USERNAME=admin,PASSWORD=secret'. "
"Keys from --credentials-file are loaded first; inline values override on collision.",
)
parser.add_argument(
"--credentials-file",
type=str,
help="Path to a JSON file of credential key-value pairs "
'(e.g., \'{"USERNAME": "admin", "PASSWORD": "secret"}\'). '
"Values are kept out of the LLM conversation; "
"use get_credential(name) in instructions to reference them. "
"Inline --credentials values override file values on key collision.",
)
parser.add_argument(
"-n",
"--non-interactive",
@ -452,6 +537,12 @@ Examples:
except Exception as e:
parser.error(f"Failed to read instruction file '{instruction_path}': {e}")
args.credentials = _parse_credentials(
args.credentials,
args.credentials_file,
parser,
)
args.user_explicit_instruction = args.instruction if args.resume else None
if args.resume:

View file

@ -744,6 +744,7 @@ class StrixTUIApp(App): # type: ignore[misc]
"scope_mode": getattr(args, "scope_mode", "auto"),
"diff_base": getattr(args, "diff_base", None),
"resume_instruction": getattr(args, "user_explicit_instruction", None) or "",
"credentials": getattr(args, "credentials", {}) or {},
}
def _setup_cleanup_handlers(self) -> None:

View file

View file

@ -0,0 +1,40 @@
"""Credential placeholder substitution utilities for Strix agents."""
from __future__ import annotations
import re
_PLACEHOLDER_RE = re.compile(r"\{\{([A-Za-z0-9_]+)\}\}")
def substitute_credentials(text: str, credentials: dict[str, str]) -> str:
"""Replace ``{{NAME}}`` tokens in *text* with matching credential values.
Tokens whose names are not in *credentials* are left unchanged.
Empty-string credential values substitute to an empty string.
"""
if not credentials:
return text
def _replace(m: re.Match[str]) -> str:
name = m.group(1)
return credentials[name] if name in credentials else m.group(0)
return _PLACEHOLDER_RE.sub(_replace, text)
def scrub_credentials(text: str, credentials: dict[str, str]) -> str:
"""Replace literal credential values in *text* with ``[CREDENTIAL:NAME]``.
Values shorter than 4 characters are not scrubbed to avoid false-positive
replacement of common substrings. Longer values are replaced first so that
a longer secret that contains a shorter one is handled correctly.
"""
pairs = sorted(
((v, k) for k, v in credentials.items() if len(v) >= 4),
key=lambda x: -len(x[0]),
)
for value, name in pairs:
text = re.sub(re.escape(value), f"[CREDENTIAL:{name}]", text)
return text

0
tests/__init__.py Normal file
View file

View file

@ -0,0 +1,167 @@
"""Tests for credential placeholder substitution and output scrubbing."""
from __future__ import annotations
import asyncio
from typing import Any
from unittest.mock import MagicMock
from agents.tool import FunctionTool
from strix.agents.factory import _wrap_credential_substitution
from strix.tools.credentials.tool import scrub_credentials, substitute_credentials
# ---------------------------------------------------------------------------
# substitute_credentials
# ---------------------------------------------------------------------------
def test_substitute_known_placeholder() -> None:
creds = {"PASSWORD": "s3cr3t"}
assert substitute_credentials("pass={{PASSWORD}}", creds) == "pass=s3cr3t"
def test_substitute_unknown_placeholder_unchanged() -> None:
creds = {"PASSWORD": "s3cr3t"}
assert substitute_credentials("{{UNKNOWN}}", creds) == "{{UNKNOWN}}"
def test_substitute_multiple_placeholders() -> None:
creds = {"USER": "admin", "PASS": "hunter2"}
result = substitute_credentials("curl -u {{USER}}:{{PASS}} http://x", creds)
assert result == "curl -u admin:hunter2 http://x"
def test_substitute_empty_value() -> None:
creds = {"EMPTY": ""}
assert substitute_credentials("x={{EMPTY}}!", creds) == "x=!"
def test_substitute_case_sensitive() -> None:
creds = {"PASSWORD": "s3cr3t"}
# lowercase key does not match uppercase credential
assert substitute_credentials("{{password}}", creds) == "{{password}}"
def test_substitute_no_credentials_returns_unchanged() -> None:
assert substitute_credentials("{{PASSWORD}}", {}) == "{{PASSWORD}}"
def test_substitute_text_without_placeholders() -> None:
creds = {"PASSWORD": "s3cr3t"}
assert substitute_credentials("no placeholders here", creds) == "no placeholders here"
# ---------------------------------------------------------------------------
# scrub_credentials
# ---------------------------------------------------------------------------
def test_scrub_long_value_replaced() -> None:
creds = {"PASSWORD": "supersecret"}
result = scrub_credentials("output: supersecret done", creds)
assert result == "output: [CREDENTIAL:PASSWORD] done"
def test_scrub_short_value_not_replaced() -> None:
# Values shorter than 4 chars must not be scrubbed
creds = {"PIN": "123"}
result = scrub_credentials("code 123 here", creds)
assert result == "code 123 here"
def test_scrub_multiple_occurrences() -> None:
creds = {"TOKEN": "abcd1234"}
result = scrub_credentials("token=abcd1234 and again abcd1234", creds)
assert result == "token=[CREDENTIAL:TOKEN] and again [CREDENTIAL:TOKEN]"
def test_scrub_longest_first_prevents_partial_overlap() -> None:
# "password" is a prefix of "password123"; longest must be replaced first
creds = {"SHORT": "pass", "LONG": "password123"}
result = scrub_credentials("password123", creds)
# "pass" (len 4) would match inside "password123", but "password123" (len 11)
# is replaced first, leaving no "pass" substring.
assert result == "[CREDENTIAL:LONG]"
def test_scrub_no_credentials_returns_unchanged() -> None:
assert scrub_credentials("some output", {}) == "some output"
def test_scrub_value_exactly_4_chars() -> None:
creds = {"KEY": "abcd"}
result = scrub_credentials("value abcd here", creds)
assert result == "value [CREDENTIAL:KEY] here"
# ---------------------------------------------------------------------------
# _wrap_credential_substitution integration
# ---------------------------------------------------------------------------
def test_wrap_substitutes_input_and_scrubs_output() -> None:
received_inputs: list[str] = []
async def inner(_ctx: Any, raw_input: str) -> str:
received_inputs.append(raw_input)
# Echo back the substituted value so we can verify scrubbing
return "executed with supersecret done"
original = FunctionTool(
name="test_tool",
description="test",
params_json_schema={"type": "object", "properties": {}},
on_invoke_tool=inner,
)
wrapped = _wrap_credential_substitution(original)
ctx = MagicMock()
ctx.context = {"credentials": {"PASSWORD": "supersecret"}}
result = asyncio.run(wrapped.on_invoke_tool(ctx, '{"cmd": "login {{PASSWORD}}"}'))
# Input must have placeholder replaced
assert received_inputs == ['{"cmd": "login supersecret"}']
# Output must have the value scrubbed
assert "supersecret" not in result
assert "[CREDENTIAL:PASSWORD]" in result
def test_wrap_does_not_mutate_original_tool() -> None:
async def inner(_ctx: Any, raw_input: str) -> str:
return raw_input
original = FunctionTool(
name="singleton_tool",
description="test",
params_json_schema={"type": "object", "properties": {}},
on_invoke_tool=inner,
)
original_invoke = original.on_invoke_tool
_wrap_credential_substitution(original)
# The original tool must not be mutated
assert original.on_invoke_tool is original_invoke
def test_wrap_passthrough_when_no_credentials() -> None:
async def inner(_ctx: Any, _raw_input: str) -> str:
return "result with {{PASSWORD}}"
original = FunctionTool(
name="tool",
description="test",
params_json_schema={"type": "object", "properties": {}},
on_invoke_tool=inner,
)
wrapped = _wrap_credential_substitution(original)
ctx = MagicMock()
ctx.context = {"credentials": {}}
result = asyncio.run(wrapped.on_invoke_tool(ctx, "{{PASSWORD}}"))
# No substitution or scrubbing when credentials dict is empty
assert result == "result with {{PASSWORD}}"

View file

@ -0,0 +1,34 @@
"""Tests for credential_names in build_scope_context."""
from __future__ import annotations
from strix.core.inputs import build_scope_context
def _base_config() -> dict:
return {
"targets": [
{
"type": "web_application",
"original": "https://example.com",
"details": {"target_url": "https://example.com"},
}
]
}
def test_no_credentials_gives_no_credential_names():
ctx = build_scope_context(_base_config())
assert ctx.get("credential_names") == []
def test_credentials_appear_as_sorted_names():
config = {**_base_config(), "credentials": {"PASSWORD": "s", "USERNAME": "u"}}
ctx = build_scope_context(config)
assert ctx["credential_names"] == ["PASSWORD", "USERNAME"]
def test_empty_credentials_gives_empty_list():
config = {**_base_config(), "credentials": {}}
ctx = build_scope_context(config)
assert ctx["credential_names"] == []

View file

@ -0,0 +1,97 @@
"""Tests for the _parse_credentials helper in main.py."""
from __future__ import annotations
import argparse
import json
import pytest
from strix.interface.main import _parse_credentials
def _parser() -> argparse.ArgumentParser:
return argparse.ArgumentParser()
def test_no_credentials_returns_empty_dict():
result = _parse_credentials(None, None, _parser())
assert result == {}
def test_inline_single_pair():
result = _parse_credentials("PASSWORD=secret", None, _parser())
assert result == {"PASSWORD": "secret"}
def test_inline_multiple_pairs():
result = _parse_credentials("USER=admin,PASS=s3cr3t", None, _parser())
assert result == {"USER": "admin", "PASS": "s3cr3t"}
def test_inline_value_with_equals_sign():
"""Values that contain '=' should be preserved after the first '='."""
result = _parse_credentials("TOKEN=abc=def", None, _parser())
assert result == {"TOKEN": "abc=def"}
def test_credentials_file(tmp_path):
creds = {"API_KEY": "abc123", "TOKEN": "xyz789"}
f = tmp_path / "creds.json"
f.write_text(json.dumps(creds))
result = _parse_credentials(None, str(f), _parser())
assert result == creds
def test_credentials_file_overridden_by_inline(tmp_path):
"""Inline values override file values for the same key."""
f = tmp_path / "creds.json"
f.write_text(json.dumps({"USER": "file_user", "PASS": "file_pass"}))
result = _parse_credentials("PASS=override", str(f), _parser())
assert result == {"USER": "file_user", "PASS": "override"}
def test_missing_file_raises_system_exit():
with pytest.raises(SystemExit):
_parse_credentials(None, "/nonexistent/creds.json", _parser())
def test_invalid_json_raises_system_exit(tmp_path):
bad = tmp_path / "bad.json"
bad.write_text("not json {{{")
with pytest.raises(SystemExit):
_parse_credentials(None, str(bad), _parser())
def test_non_object_json_raises_system_exit(tmp_path):
bad = tmp_path / "list.json"
bad.write_text(json.dumps(["a", "b"]))
with pytest.raises(SystemExit):
_parse_credentials(None, str(bad), _parser())
def test_invalid_inline_format_raises_system_exit():
with pytest.raises(SystemExit):
_parse_credentials("NOEQUALS", None, _parser())
def test_empty_key_raises_system_exit():
with pytest.raises(SystemExit):
_parse_credentials("=value", None, _parser())
def test_non_string_json_values_raise_system_exit(tmp_path):
bad = tmp_path / "bad.json"
bad.write_text(json.dumps({"KEY": {"nested": "object"}}))
with pytest.raises(SystemExit):
_parse_credentials(None, str(bad), _parser())
def test_invalid_key_characters_raise_system_exit():
with pytest.raises(SystemExit):
_parse_credentials("MY-API-KEY=value", None, _parser())
def test_key_with_dot_raises_system_exit():
with pytest.raises(SystemExit):
_parse_credentials("API.KEY=value", None, _parser())