mirror of
https://github.com/usestrix/strix.git
synced 2026-10-06 02:48:10 +00:00
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:
parent
250fe2cf3e
commit
664d64a98c
17 changed files with 538 additions and 16 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
0
strix/tools/credentials/__init__.py
Normal file
0
strix/tools/credentials/__init__.py
Normal file
40
strix/tools/credentials/tool.py
Normal file
40
strix/tools/credentials/tool.py
Normal 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
0
tests/__init__.py
Normal file
167
tests/test_credential_substitution.py
Normal file
167
tests/test_credential_substitution.py
Normal 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}}"
|
||||
34
tests/test_credentials_context.py
Normal file
34
tests/test_credentials_context.py
Normal 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"] == []
|
||||
97
tests/test_credentials_parsing.py
Normal file
97
tests/test_credentials_parsing.py
Normal 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())
|
||||
Loading…
Add table
Reference in a new issue