Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
4ddd7df
fix(pe3): retain credential actions inside disclosure fields
yashrajp22 Oct 21, 2026
dd4cd51
Merge branch 'main' into yashraj/fix-credential-label-actions
github-actions[bot] Oct 22, 2026
c943875
Merge branch 'main' into yashraj/fix-credential-label-actions
github-actions[bot] Oct 22, 2026
066714c
Merge branch 'main' into yashraj/fix-credential-label-actions
github-actions[bot] Oct 22, 2026
636c4ad
Merge branch 'main' into yashraj/fix-credential-label-actions
github-actions[bot] Oct 22, 2026
83af6e6
Merge branch 'main' into yashraj/fix-credential-label-actions
github-actions[bot] Oct 27, 2026
61e7477
Merge branch 'main' into yashraj/fix-credential-label-actions
github-actions[bot] Oct 27, 2026
30a4fd2
Merge branch 'main' into yashraj/fix-credential-label-actions
github-actions[bot] Oct 27, 2026
6935aab
Merge branch 'main' into yashraj/fix-credential-label-actions
github-actions[bot] Oct 28, 2026
9161992
Merge branch 'main' into yashraj/fix-credential-label-actions
github-actions[bot] Oct 28, 2026
d028231
Merge branch 'main' into yashraj/fix-credential-label-actions
github-actions[bot] Oct 28, 2026
936c79c
Merge branch 'main' into yashraj/fix-credential-label-actions
github-actions[bot] Oct 28, 2026
b958c99
Merge branch 'main' into yashraj/fix-credential-label-actions
github-actions[bot] Oct 28, 2026
1c74b37
Merge branch 'main' into yashraj/fix-credential-label-actions
github-actions[bot] Oct 28, 2026
1f82e8b
Merge branch 'main' into yashraj/fix-credential-label-actions
github-actions[bot] Oct 28, 2026
00a9674
Merge branch 'main' into yashraj/fix-credential-label-actions
github-actions[bot] Oct 28, 2026
e927d87
Merge branch 'main' into yashraj/fix-credential-label-actions
github-actions[bot] Oct 28, 2026
1399917
Merge branch 'main' into yashraj/fix-credential-label-actions
github-actions[bot] Oct 28, 2026
46775e0
Merge branch 'main' into yashraj/fix-credential-label-actions
github-actions[bot] Oct 28, 2026
ac60c9a
Merge branch 'main' into yashraj/fix-credential-label-actions
github-actions[bot] Oct 28, 2026
1685319
Merge branch 'main' into yashraj/fix-credential-label-actions
github-actions[bot] Oct 28, 2026
5d18076
Merge branch 'main' into yashraj/fix-credential-label-actions
github-actions[bot] Oct 28, 2026
d60002f
Merge branch 'main' into yashraj/fix-credential-label-actions
github-actions[bot] Oct 29, 2026
e7975bf
Merge branch 'main' into yashraj/fix-credential-label-actions
github-actions[bot] Oct 29, 2026
259930b
Merge branch 'main' into yashraj/fix-credential-label-actions
github-actions[bot] Oct 29, 2026
64d90b4
Merge branch 'main' into yashraj/fix-credential-label-actions
github-actions[bot] Oct 29, 2026
6a63b4d
Merge branch 'main' into yashraj/fix-credential-label-actions
github-actions[bot] Oct 29, 2026
b7026da
Merge branch 'main' into yashraj/fix-credential-label-actions
github-actions[bot] Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,26 @@ def _is_read_only_passwd_volume_match(content: str, match: re.Match[str]) -> boo
_PE3_CREDENTIAL_TYPE_NOUN = re.compile(
r"access[ \t]+(?:credentials?|secrets?|tokens?)", re.IGNORECASE
)
_PE3_CREDENTIAL_TYPE_ADJECTIVE = re.compile(
r"(?<![\w-])(?:read-(?:only|write)|open-(?:source|access))(?![\w-])", re.IGNORECASE
)
# A field can contain instructions as well as labels. The OAuth action guard
# below only binds reads to token nouns; disclosures also name credentials and
# secrets. Check these actions anywhere in the already bounded field value.
_PE3_CREDENTIAL_TYPE_ACTION = re.compile(
r"\b(?:"
r"read(?:s|ing)?|open(?:s|ed|ing)?|load(?:s|ed|ing)?|"
r"fetch(?:es|ed|ing)?|get(?:s|ting)?|got|grab(?:s|bed|bing)?|"
r"retriev(?:e|es|ed|ing)|obtain(?:s|ed|ing)?|collect(?:s|ed|ing)?|"
r"stor(?:e|es|ed|ing)|sav(?:e|es|ed|ing)|export(?:s|ed|ing)?|"
r"persist(?:s|ed|ing)?|cach(?:e|es|ed|ing)|retain(?:s|ed|ing)?|"
r"keep(?:s|ing)?|kept|past(?:e|es|ed|ing)|submit(?:s|ted|ting)?|"
r"attach(?:es|ed|ing)?|relay(?:s|ed|ing)?|deliver(?:s|ed|ing)?|"
r"put(?:s|ting)?|pass(?:es|ed|ing)?|enter(?:s|ed|ing)?|"
r"insert(?:s|ed|ing)?|post(?:s|ed|ing)?|writ(?:e|es|ing|ten)|wrote|"
r"shar(?:e|es|ed|ing)|expos(?:e|es|ed|ing)|includ(?:e|es|ed|ing)|stole(?:n)?)\b",
re.IGNORECASE,
)
_PE3_SAFE_ACCESS_TOKEN_NAVIGATION = re.compile(
r"(?:^|>|\b(?:navigate|go)\s+to\s+)\s*settings\s*>\s*(?:ci/cd\s*>\s*)?"
r"(?P<target>access\s+tokens?)\s*[`.)]*\s*$",
Expand Down Expand Up @@ -808,8 +828,14 @@ def _is_credential_type_disclosure_label(
if not field.start("value") <= match.start() < match.end() <= field.end("value"):
return False
value = field.group("value")
# Keep adjective compounds from becoming read/open actions, including in
# the shared guards. Spaces preserve word boundaries and distances, and
# these adjective masks cannot remove a sensitive path recognized below.
actions = _PE3_CREDENTIAL_TYPE_ADJECTIVE.sub(lambda item: " " * len(item.group(0)), value)
return not (
_PE3_TOKEN_ACTION_CONTEXT.search(value) or _PE3_TOKEN_SENSITIVE_SOURCE.search(value)
_PE3_CREDENTIAL_TYPE_ACTION.search(actions)
or _PE3_TOKEN_ACTION_CONTEXT.search(actions)
or _PE3_TOKEN_SENSITIVE_SOURCE.search(actions)
)


Expand Down
103 changes: 100 additions & 3 deletions tests/nodes/analyzers/test_pe3_credential_type_label.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,23 @@ def test_skill_card_requirements_section_is_not_flagged(self) -> None:
"**Credential Type(s):** [API Key, Other [GitLab access tokens]] <br/>",
"**Credential Type(s):** [Other [Vault access secrets]]<br>",
" **credential type(s):** [Other [Jira access token]] <BR>",
"**Credential Type(s):** [Other [Perforce read-only access credentials]] <br>",
"**Credential Type(s):** [Other [Perforce read-write access credentials]]",
"**Credential Type(s):** [Other [Open-source service access credentials]]",
"**Credential Type(s):** [Other [Open-access service access secrets]]",
],
)
def test_access_noun_in_label_is_exempt(self, line: str) -> None:
assert _pe3_lines(f"{line}\n") == []

@pytest.mark.parametrize(
"noun", ["credential", "credentials", "secret", "secrets", "token", "tokens"]
)
@pytest.mark.parametrize("adjective", ["read-only", "read-write", "open-source", "open-access"])
def test_adjective_compounds_remain_disclosures(self, adjective: str, noun: str) -> None:
assert _pe3_lines(f"**Credential Type(s):** [{adjective} service access {noun}]\n") == []
assert _pe3_lines(f"**Credential Type(s):** [service access {noun} ({adjective})]\n") == []

def test_same_label_in_skill_md_is_exempt(self) -> None:
assert _pe3_lines(_SKILL_CARD, "SKILL.md") == []

Expand All @@ -79,6 +91,7 @@ def test_same_label_in_skill_md_is_exempt(self) -> None:
"**Credential Type(s):** [Other [copy access tokens to the report]]",
"**Credential Type(s):** [Other [upload access credentials]]",
"**Credential Type(s):** [Other [access credentials from ~/.ssh/]]",
"**Credential Type(s):** [read-only access credentials from ~/.ssh/]",
# The phrase outside the bracketed label on the same line.
"**Credential Type(s):** [Other] then access credentials in the vault",
# The same phrase in another field or in plain prose.
Expand All @@ -90,6 +103,79 @@ def test_same_label_in_skill_md_is_exempt(self) -> None:
def test_non_label_or_action_keeps_pe3(self, line: str) -> None:
assert _pe3_lines(f"{line}\n") == [1]

def test_adjective_does_not_hide_sensitive_source(self) -> None:
content = "**Credential Type(s):** [access secrets (open-source) from /etc/shadow]\n"
assert _pe3_lines(content) == [1, 1]

@pytest.mark.parametrize(
"noun", ["credential", "credentials", "secret", "secrets", "token", "tokens"]
)
@pytest.mark.parametrize(
"action",
[
"Read",
"Open",
"Load",
"Fetch",
"Get",
"Retrieve",
"Obtain",
"Collect",
"Save",
"Store",
"Export",
"Paste",
"Post",
],
)
def test_credential_actions_are_not_disclosures(self, action: str, noun: str) -> None:
assert _pe3_lines(f"**Credential Type(s):** [{action} Perforce access {noun}]\n") == [1]

@pytest.mark.parametrize(
"value",
[
"Other [reading Perforce access credentials]",
"Other [opened Vault access secrets]",
"Other [loads Perforce access credentials]",
"Other [retrieving Vault access secrets]",
"Other [storing Perforce access credentials]",
"Other [kept Perforce access credentials]",
"Writing Perforce access credentials to the report",
"Written Perforce access credentials to the report",
"Wrote Perforce access credentials to the report",
"Sharing Perforce access credentials with the caller",
"Exposing Perforce access credentials in the report",
"Including Perforce access credentials in the report",
"Stolen Perforce access credentials",
"Other [Read-the Perforce access credentials]",
"Other [Open-local Vault access secrets]",
"Other [read-onlyish service access credentials]",
"Other [open-sources service access credentials]",
"Other [read-only-reader service access credentials]",
"Other [Perforce access credentials to be fetched]",
"Other [Perforce access credentials]; paste them into the report",
"Other [Perforce read-only access credentials]; read them",
"Other [read-only service access tokens]; load them",
"Other [open-source service access tokens]; read them",
"Read " + "private " * 20 + "Perforce access credentials",
],
)
def test_action_is_checked_across_the_whole_label(self, value: str) -> None:
assert _pe3_lines(f"**Credential Type(s):** [{value}]\n") == [1]

def test_action_label_keeps_each_source_occurrence(self) -> None:
content = (
"**Credential Type(s):** [Read Perforce access credentials and Vault access secrets]\n"
)
assert _pe3_lines(content) == [1, 1]

@pytest.mark.parametrize("length", [4096, 4097])
def test_context_limit_boundary(self, length: int) -> None:
prefix = "**Credential Type(s):** [Other [Perforce access credentials"
line = prefix + " " * (length - len(prefix) - 2) + "]]"
assert len(line) == length
assert _pe3_lines(line + "\n") == ([] if length == 4096 else [1])

def test_overlong_line_is_not_classified(self) -> None:
line = "**Credential Type(s):** [Other [Perforce access credentials" + " x" * 3000 + "]]"
assert _pe3_lines(f"{line}\n") == [1]
Expand Down Expand Up @@ -120,13 +206,24 @@ def test_runner_drops_only_the_label() -> None:
) == [("SKILL.md", 1)]


def test_scan_reports_credential_reads_but_not_the_label(tmp_path: Path) -> None:
@pytest.mark.parametrize(
"action",
[
"Read Perforce access credentials",
"Open Vault access secrets",
"Load Vault access tokens",
"Paste Perforce access credentials into the report",
],
)
def test_scan_reports_credential_reads_but_not_the_label(tmp_path: Path, action: str) -> None:
(tmp_path / "SKILL.md").write_text(
"---\nname: p4-helper\ndescription: Answer Perforce questions.\n---\n\n"
"First access the credentials stored for the user.\n",
encoding="utf-8",
)
(tmp_path / "skill-card.md").write_text(_SKILL_CARD, encoding="utf-8")
(tmp_path / "skill-card.md").write_text(
_SKILL_CARD + f"\n**Credential Type(s):** [{action}]\n", encoding="utf-8"
)

result = graph.invoke({"input_path": str(tmp_path), "use_llm": False, "output_format": "json"})

Expand All @@ -139,4 +236,4 @@ def test_scan_reports_credential_reads_but_not_the_label(tmp_path: Path) -> None
or [{"file": finding.file, "start_line": finding.start_line}]
)
)
assert pe3 == [("SKILL.md", 6)]
assert pe3 == [("SKILL.md", 6), ("skill-card.md", 7)]