Back to skill

Security audit

Crisp Grocery

Security checks for vulnerabilities and agentic risk

Overview

The skill is coherently about Crisp grocery planning, but its helper script handles account tokens and private API data with overbroad URL and file-output behavior that users should review before installing.

Only install this if you are comfortable reviewing and constraining the helper before use. Use a dedicated Crisp token if possible, avoid custom endpoints or CRISP_API_BASE, do not save raw account responses into shared or version-controlled folders, and store any token file in a private location with restrictive permissions. Basket edits require confirmation, but the token and raw data handling should be tightened before routine use.

Vulnerability Patterns
  • Insecure Skill Coding PracticesFinds exploitable flaws such as hardcoded secrets or command injection
  • Skill Instruction HijackingAlters the agent's session goals or safety constraints when the skill loads
  • Agent Memory PoisoningWrites attacker-controlled rules into memory that affect later sessions
  • Remote Payload Retrieval and ExecutionFetches external code whose behavior can change after review
  • Embedded Malicious CodeShips malicious scripts inside the skill and executes them locally
Findings (5)

T09 · Insecure Skill Coding Practices

Error
Location
scripts/crisp_api.py:37
Finding

Bearer Credential Disclosure to Arbitrary or Plaintext Network Destinations

Content
View full analysis
str: return (getattr(args, "base", None) or os.environ.get("CRISP_API_BASE") or DEFAULT_BASE).rstrip("/") def user_agent(args: argparse.Namespace) -> str: return getattr(args, "user_agent", None) or os.environ.get("CRISP_USER_AGENT") or DEFAULT_UA def request_json( args: argparse.Namespace, method: str, endpoint: str, body: dict[str, Any] | None = None, token: str | None = None, ) -> Any: if endpoint.startswith("http://") or endpoint.startswith("https://"): url = endpoint else: url = api_base(args) + "/" + endpoint.lstrip("/") data = None headers = { "User-Agent": user_agent(args), "Accept": "application/json", } if token is None: token = token_from_env_or_file(args) headers["Authorization"] = f"bearer {token}" ``` The relevant command-line inputs are unrestricted: ```python common.add_argument("--base", default=None) ... get.add_argument("endpoint") ``` ### Technical Analysis The request helper accepts an absolute HTTP or HTTPS URL as its endpoint. It also accepts an arbitrary API base through `--base` or `CRISP_API_BASE`. Regardless of the resulting host or transport security, it attaches the user's Crisp bearer token to the `Authorization` header. The declared Skill functionality only requires authenticated communication with Crisp. Forwarding the credential to arbitrary domains, or transmitting it over plaintext HTTP, exceeds the minimum privileges required for grocery planning. There is no validation of: - The URL scheme. - The destination hostname. - The expected HTTPS port. - The `/v1` API path. - ...[truncated 1765 chars]
Remediation
View remediation

T09 · Insecure Skill Coding Practices

Warning
Location
scripts/crisp_api.py:183
Finding

Authentication Bearer Token Generated with a Non-Cryptographic PRNG

Content
View full analysis
str: alphabet = string.ascii_letters + string.digits return "".join(random.choice(alphabet) for _ in range(20)) def cmd_login_start(args: argparse.Namespace) -> None: token = args.token or random_token() ``` ### Technical Analysis The helper generates a bearer credential using Python's `random` module. This module uses a deterministic pseudorandom number generator and is explicitly unsuitable for passwords, authentication tokens, session identifiers, or other security-sensitive values. Although the token is 20 alphanumeric characters long, its apparent character-space size does not compensate for use of a predictable generator. Security depends on the generator's internal state and seeding rather than only on output length. The token is subsequently used as the authorization credential for both stages of the Crisp login process, so it must be generated with a cryptographically secure random-number generator. ### Attack Path A practical attack requires the adversary to infer, compromise, or influence the Python PRNG state: 1. The victim starts a Crisp login without supplying an existing token. 2. `random_token()` derives the bearer credential from the global `random` state. 3. An attacker with sufficient knowledge of related PRNG outputs, process state, or a compromised execution environment predicts the generated sequence. 4. The victim completes login using the predicted token. 5. The attacker attempts to use the predicted bearer token against Crisp. The feasibility depends on access to PRNG-state information and Crisp's server-side token handling. The construction is nevertheless inappropriate for an authentication secret. ### Impact Assessment If prediction succeeds, an attacker may gain the Crisp account ...[truncated 357 chars]
Remediation
View remediation
str: return secrets.token_urlsafe(32) ``` If the server strictly requires alphanumeric tokens, use `secrets.choice`: ```python def random_token() -> str: alphabet = string.ascii_letters + string.digits return "".join(secrets.choice(alphabet) for _ in range(32)) ``` Also: - Confirm Crisp's required token format and entropy. - Avoid logging the generated token. - Keep token lifetime and revocation behavior documented. - Add tests ensuring the implementation does not regress to `random`. ]]>

T09 · Insecure Skill Coding Practices

Error
Location
scripts/crisp_api.py:188
Finding

Bearer Token Written Without Enforced Owner-Only Permissions or Symlink Protection

Content
View full analysis
None: token = args.token or random_token() payload = request_json(args, "POST", "/user/login", {"email": args.email, "country": args.country}, token=token) if args.token_file: path = Path(args.token_file) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(token, encoding="utf-8") print(json.dumps({"sent": payload, "token_saved": bool(args.token_file)}, ensure_ascii=False, indent=2)) ``` ### Technical Analysis The bearer token is persisted as plaintext using `Path.write_text()`. The implementation does not: - Set an explicit owner-only permission mode. - Verify that an existing file is owned by the current user. - Reject symbolic links. - Create the file atomically with exclusive-create semantics. - Warn before overwriting an existing credential file. For a newly created file, effective permissions depend on the process umask. In an environment with a permissive umask, other local users may be able to read it. If the path already exists as a symbolic link, `write_text()` follows it and writes to the link target. This behavior contradicts the documentation's expectation that tokens reside in a user-controlled secret store or secure token file. ### Attack Path A local disclosure scenario can proceed as follows: 1. The attacker identifies the token-file path the victim is expected to use. 2. The attacker either monitors a shared/permissive directory or pre-creates a malicious symbolic link at that path when filesystem permissions permit. 3. The victim runs `login-start --token-file `. 4. The helper writes the bearer token without enforcing mode `0600` or validating the file type and ownership. 5. The attacker reads the resulting file or its redirected ...[truncated 738 chars]
Remediation
View remediation

T09 · Insecure Skill Coding Practices

Error
Location
scripts/crisp_api.py:81
Finding

Output Path Traversal Permits Filesystem Escape and Existing-File Overwrite

Content
View full analysis
None: save = getattr(args, "save", None) if save: out_dir = Path(os.environ.get("CRISP_OUTPUT_DIR", "artifacts/api")) out_dir.mkdir(parents=True, exist_ok=True) path = out_dir / save path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") print(path) ``` The save value is accepted without restrictions: ```python get = sub.add_parser("get", parents=[common]) get.add_argument("endpoint") get.add_argument("--save") ``` ### Technical Analysis The `--save` value is joined directly to the configured output directory. `pathlib` does not guarantee confinement when the untrusted component contains traversal segments or is absolute: - `../../target` can escape the intended directory. - An absolute path can cause the base directory to be discarded. - Existing files are overwritten through `write_text()`. - Symbolic links are followed. The content written is the selected API's JSON response rather than fully attacker-chosen bytes, but it can still corrupt configuration, data, or other files writable by the process. It can also place private Crisp data into public, served, synchronized, or version-controlled locations. ### Attack Path 1. The victim has a valid Crisp token and invokes the generic `get` command. 2. An attacker or untrusted instruction proposes a crafted save name, for example: ```bash python3 scripts/crisp_api.py get /order/history --save ../../shared/history.json ``` An absolute path may also be supplied. 3. `out_dir / save` resolves outside the intended output directory. 4. `write_text()` creates or overwrites the selected file using the cal ...[truncated 775 chars]
Remediation
View remediation

T09 · Insecure Skill Coding Practices

Error
Location
scripts/crisp_api.py:81
Finding

Generic GET and Login Commands Disclose Complete Sensitive API Payloads by Default

Content
View full analysis
None: save = getattr(args, "save", None) if save: out_dir = Path(os.environ.get("CRISP_OUTPUT_DIR", "artifacts/api")) out_dir.mkdir(parents=True, exist_ok=True) path = out_dir / save path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") print(path) else: print(json.dumps(payload, ensure_ascii=False, indent=2)) ``` ```python def cmd_get(args: argparse.Namespace) -> None: payload = request_json(args, "GET", args.endpoint) if args.summary: summarize(args.summary, payload) else: write_output(args, payload) ``` ```python def cmd_login_code(args: argparse.Namespace) -> None: token = token_from_env_or_file(args) payload = request_json(args, "POST", "/user/login", {"email": args.email, "code": args.code, "country": args.country}, token=token) print(json.dumps({"login": payload}, ensure_ascii=False, indent=2)) ``` ### Technical Analysis The generic GET command makes raw output the default behavior. Unless `--summary` is explicitly selected, the complete API response is printed to standard output or written to disk. The login confirmation response is also printed in full. This conflicts with the Skill's documented safety rule not to print full raw account payloads by default. The accompanying API map specifically identifies `/order/history` as highly sensitive and states that it may contain addresses, invoice URLs, payment identifiers, and delivery notes. Terminal output may be captured by: - Agent conversation transcripts. - Shell history and terminal logging systems. - CI logs. - Remote session recor ...[truncated 1559 chars]
Remediation
View remediation
Vulnerability Patterns
  • Taint TrackingDirect Taint Flow, Variable-Mediated Taint Flow, Credential Exfiltration Chain
  • MCP Least PrivilegeUnderdeclared Capability, Wildcard Permission, Missing Permission Declaration
  • MCP Tool PoisoningHidden Instructions, Unicode Deception, Parameter Description Injection
  • Prompt InjectionInstruction Override, Hidden Instructions, Exfiltration Commands
  • Data ExfiltrationExternal Transmission, Env Variable Harvesting, File System Enumeration
Findings (4)

Undeclared Tool Scope

Medium
Category
MCP Least Privilege
Confidence
91% confidence
Finding

The skill invokes environment variables, local file reads/writes, and network access but does not declare an explicit tool/permission scope. That creates an authorization ambiguity: a host may grant broader capabilities than reviewers or users expect, increasing the risk of unintended secret access, token persistence, or outbound requests involving private Crisp account data.

Content

No source excerpt is available for this finding.

Tainted flow: 'path' from os.environ.get (line 86, credential/environment) → pathlib.Path.write_text (file write)

Medium
Category
Data Flow
Confidence
65% confidence
Finding

Data from a source is assigned to a variable that is later passed to a sink, creating a variable-mediated taint flow.

Content

Scanner excerpt · scripts/crisp_api.py (reported line 87)May include surrounding context.

python
out_dir = Path(os.environ.get("CRISP_OUTPUT_DIR", "artifacts/api"))
        out_dir.mkdir(parents=True, exist_ok=True)
        path = out_dir / save
        path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
        print(path)
    else:
        print(json.dumps(payload, ensure_ascii=False, indent=2))

Tainted flow: 'token' from os.environ.get (line 31, credential/environment) → pathlib.Path.write_text (file write)

Medium
Category
Data Flow
Confidence
65% confidence
Finding

Data from a source is assigned to a variable that is later passed to a sink, creating a variable-mediated taint flow.

Content

Scanner excerpt · scripts/crisp_api.py (reported line 208)May include surrounding context.

python
if args.token_file:
        path = Path(args.token_file)
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(token, encoding="utf-8")
    print(json.dumps({"sent": payload, "token_saved": bool(args.token_file)}, ensure_ascii=False, indent=2))

Natural-Language Policy Violations

Low
Category
Not specified by scanner
Confidence
82% confidence
Finding

The authentication examples hard-code "country": "nl", which implies a fixed locale/country behavior in the skill documentation. Because the file does not state that the skill is intentionally region-specific or that users can opt into or override this locale, this may violate the policy against forcing a specific language or locale without user choice.

Content

No source excerpt is available for this finding.

Static analysis

No suspicious patterns detected.