deer-flow/backend/tests/test_skillscan_secret_assignment_formats.py
sxh ee44d1ebc7
fix(skills): report secret assignments whose key is quoted (#6276)
* fix(skills): report secret assignments whose key is quoted

The line-oriented secret sweep required the credential name to be followed
directly by ':' or '=', so a JSON config ('{"api_key": "..."}') never matched
while the same binding in YAML, .env, .ini or shell did. Allow one optional
quote between the key and the separator.

* fix(skills): match secret assignments whose key has a separator prefix

The line-oriented sweep anchored the credential word with `\b`, which cannot
land after `_` because `_` is a word character. So `access_token`,
`client_secret` and `MY_API_KEY` — the common spellings in OAuth and
service-account config — matched nothing, and `_SECRET_TOKEN_PATTERNS` does
not backstop JWT- or `GOCSPX-`-style values, so such a package produced no
finding at all.

Guard the word with "not preceded by an alphanumeric" instead: a separator
(`_`, `.`, `-`) may now introduce it, while a run of letters (`tokenizer`,
`secretive`, `credentials_file`) still stays quiet.

---------

Co-authored-by: sxh313 <sxh313@users.noreply.github.com>
2026-10-04 21:15:18 +08:00

154 lines
6.2 KiB
Python

"""Line-oriented `secret-env-assignment` sweep: every spelling of the same binding.
The rule runs over text (`_scan_secret_assignments_by_text`), so the *format* must not
decide whether a credential is reported: `api_key: <secret>` in YAML, `API_KEY=<secret>`
in `.env`/`.ini`/shell and `{"api_key": "<secret>"}` in JSON are the same binding and have
to read the same way. The quoted-key shapes below are what a JSON config uses.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from deerflow.skills.skillscan.orchestrator import _SECRET_ASSIGNMENT_RE, scan_skill_dir
SECRET = "9f8e7d6c5b4a3210ff"
# Key on the left, value on the right, with the key quoted the way each format spells it.
QUOTED_KEY_CASES = [
("config.json", '{"api_key": "%s"}'),
("token.json", '{"token": "%s"}'),
("plural.json", '{"secrets": "%s"}'),
("password.json", '{"password": "%s"}'),
("hyphen.json", '{"api-key": "%s"}'),
("single.json", "{'api_key': '%s'}"),
("spaced.json", '{"api_key" : "%s"}'),
("nested.json", '{"auth": {"api_key": "%s"}}'),
("inline.json", '{"api_key": "%s", "other": 1}'),
]
UNQUOTED_KEY_CASES = [
("config.yaml", "api_key: %s"),
("config.ini", "api_key=%s"),
("app.env", "API_KEY=%s"),
]
# Shapes the rule must keep quiet about, so the quoted-key match does not become a blanket
# "any JSON string" match.
QUIET_CASES = [
("placeholder.json", '{"api_key": "your-key-here"}'),
("env-ref.json", '{"api_key": "${API_KEY}"}'),
("empty.json", '{"api_key": ""}'),
("unrelated.json", '{"other": "%s"}'),
]
# The credential word is often the tail of a longer name separated by `_`, `.` or `-`
# (`access_token`, `client_secret`, `x-api-key`). Those are the common spellings in OAuth
# and service-account config, and each must read the same as the bare name.
SUFFIXED_KEY_CASES = [
("oauth.json", '{"access_token": "%s"}'),
("client.json", '{"client_secret": "%s"}'),
("refresh.json", '{"refresh_token": "%s"}'),
("access.env", "ACCESS_TOKEN=%s"),
("client.env", "CLIENT_SECRET=%s"),
("api.env", "MY_API_KEY=%s"),
("client.yaml", "client_secret: %s"),
("access.ini", "access_token=%s"),
("dotted.yaml", "auth.token: %s"),
("hyphen.yaml", "x-api-key: %s"),
]
# A run of letters before the credential word is not a credential name: the sweep must not
# fire on ordinary words that merely contain the keyword (`tokenizer`, `secretive`).
MIDWORD_QUIET_CASES = [
("tokenizer.yaml", "tokenizer: %s"),
("secretive.yaml", "secretive: %s"),
("passwordless.yaml", "passwordless: %s"),
("credentials_file.yaml", "credentials_file: %s"),
("mytoken.yaml", "mytoken: %s"),
]
def _write_package(parent: Path, filename: str, text: str) -> Path:
root = parent / "pkg"
root.mkdir(parents=True, exist_ok=True)
(root / "SKILL.md").write_text("---\nname: demo\ndescription: A demo skill\n---\n\nBody.\n", encoding="utf-8")
(root / filename).write_text(text + "\n", encoding="utf-8")
return root
def _secret_findings(root: Path) -> list[dict]:
return [finding for finding in scan_skill_dir(root)["findings"] if finding["rule_id"] == "secret-env-assignment"]
@pytest.mark.parametrize(("filename", "template"), QUOTED_KEY_CASES)
def test_quoted_key_is_reported(tmp_path: Path, filename: str, template: str) -> None:
root = _write_package(tmp_path, filename, template % SECRET)
assert [(finding["rule_id"], finding["line"]) for finding in _secret_findings(root)] == [("secret-env-assignment", 1)]
@pytest.mark.parametrize(("filename", "template"), UNQUOTED_KEY_CASES)
def test_unquoted_key_stays_reported(tmp_path: Path, filename: str, template: str) -> None:
root = _write_package(tmp_path, filename, template % SECRET)
assert [(finding["rule_id"], finding["line"]) for finding in _secret_findings(root)] == [("secret-env-assignment", 1)]
@pytest.mark.parametrize(("filename", "template"), SUFFIXED_KEY_CASES)
def test_separator_prefixed_key_is_reported(tmp_path: Path, filename: str, template: str) -> None:
root = _write_package(tmp_path, filename, template % SECRET)
assert [(finding["rule_id"], finding["line"]) for finding in _secret_findings(root)] == [("secret-env-assignment", 1)]
@pytest.mark.parametrize(("filename", "template"), MIDWORD_QUIET_CASES)
def test_midword_key_stays_quiet(tmp_path: Path, filename: str, template: str) -> None:
root = _write_package(tmp_path, filename, template % SECRET)
assert _secret_findings(root) == []
@pytest.mark.parametrize(
"text",
[
'{"access_token": "' + SECRET + '"}',
'{"client_secret": "' + SECRET + '"}',
"ACCESS_TOKEN=" + SECRET,
"MY_API_KEY=" + SECRET,
],
)
def test_sweep_matches_separator_prefixed_keys(text: str) -> None:
"""The review's shapes: a separator may introduce the credential word."""
assert [match.group(2) for match in _SECRET_ASSIGNMENT_RE.finditer(text)] == [SECRET]
@pytest.mark.parametrize("word", ["tokenizer", "secretive", "passwordless", "mytoken"])
def test_sweep_ignores_a_letter_run_before_the_word(word: str) -> None:
"""A letter run is not a separator, so the word boundary still holds."""
assert list(_SECRET_ASSIGNMENT_RE.finditer(f"{word}: {SECRET}")) == []
@pytest.mark.parametrize(("filename", "template"), QUIET_CASES)
def test_non_credential_or_placeholder_values_stay_quiet(tmp_path: Path, filename: str, template: str) -> None:
root = _write_package(tmp_path, filename, template % SECRET if "%s" in template else template)
assert _secret_findings(root) == []
def test_json_key_spelling_reports_the_same_finding_as_yaml(tmp_path: Path) -> None:
"""The format alone must not decide whether the binding is reported."""
yaml_root = _write_package(tmp_path / "yaml", "config.yaml", f"api_key: {SECRET}")
json_root = _write_package(tmp_path / "json", "config.json", '{"api_key": "' + SECRET + '"}')
assert [finding["line"] for finding in _secret_findings(yaml_root)] == [1]
assert [finding["line"] for finding in _secret_findings(json_root)] == [1]
def test_pretty_printed_json_reports_the_line_the_key_is_on(tmp_path: Path) -> None:
root = _write_package(tmp_path, "config.json", '{\n "api_key": "' + SECRET + '"\n}')
assert [finding["line"] for finding in _secret_findings(root)] == [2]