Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
## 2025-10-26 - Trusting API IDs
**Vulnerability:** IDOR / Path Traversal risk if API returns unsafe IDs.
**Learning:** Even "trusted" upstream APIs can be compromised or buggy. Trust nothing.
**Prevention:** Validate all resource IDs (PKs) from APIs against a strict whitelist before using them in local operations or downstream requests.
1 change: 0 additions & 1 deletion .python-version

This file was deleted.

116 changes: 79 additions & 37 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -397,25 +397,34 @@
return text


def is_valid_profile_id_format(profile_id: str) -> bool:
if not re.match(r"^[a-zA-Z0-9_-]+$", profile_id):
return False
if len(profile_id) > 64:
def validate_resource_id(
resource_id: str, type_name: str = "ID", log_errors: bool = True

Check warning

Code scanning / Pylint (reported by Codacy)

Wrong hanging indentation before block (add 4 spaces). Warning

Wrong hanging indentation before block (add 4 spaces).
) -> bool:
"""
Validates a resource ID (Profile ID, Folder ID/PK).
Allowed: Alphanumeric, hyphen, underscore.
Max Length: 64
"""
if not resource_id:

Check warning

Code scanning / Pylint (reported by Codacy)

Missing function docstring Warning

Missing function docstring
return False
return True

if len(resource_id) > 64:
if log_errors:
log.error(f"Invalid {type_name} length (max 64 chars)")

Check warning

Code scanning / Prospector (reported by Codacy)

Use lazy % formatting in logging functions (logging-fstring-interpolation) Warning

Use lazy % formatting in logging functions (logging-fstring-interpolation)

Check notice

Code scanning / Pylintpython3 (reported by Codacy)

Use lazy % formatting in logging functions Note

Use lazy % formatting in logging functions
return False

def validate_profile_id(profile_id: str, log_errors: bool = True) -> bool:
if not is_valid_profile_id_format(profile_id):
if not re.match(r"^[a-zA-Z0-9_-]+$", resource_id):
if log_errors:
if not re.match(r"^[a-zA-Z0-9_-]+$", profile_id):
log.error("Invalid profile ID format (contains unsafe characters)")
elif len(profile_id) > 64:
log.error("Invalid profile ID length (max 64 chars)")
log.error(f"Invalid {type_name} format (contains unsafe characters)")

Check warning

Code scanning / Prospector (reported by Codacy)

Use lazy % formatting in logging functions (logging-fstring-interpolation) Warning

Use lazy % formatting in logging functions (logging-fstring-interpolation)

Check notice

Code scanning / Pylintpython3 (reported by Codacy)

Use lazy % formatting in logging functions Note

Use lazy % formatting in logging functions
return False
Comment on lines +416 to 419

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The regular expression ^[a-zA-Z0-9_-]+$ is recompiled every time validate_resource_id is called. For better performance, especially since this function is used for security validation in multiple places, it's a good practice to pre-compile the regex at the module level.

For example:

_RESOURCE_ID_RE = re.compile(r'^[a-zA-Z0-9_-]+$')

Then use _RESOURCE_ID_RE.match(resource_id) inside the function.


return True


def validate_profile_id(profile_id: str, log_errors: bool = True) -> bool:
return validate_resource_id(profile_id, "profile ID", log_errors)


def is_valid_rule(rule: str) -> bool:
"""
Validates that a rule is safe to use.
Expand All @@ -436,15 +445,17 @@
def is_valid_folder_name(name: str) -> bool:
"""
Validates folder name to prevent XSS and ensure printability.
Allowed: Anything printable except < > " ' `
Allowed: Alphanumeric, space, hyphen, dot, underscore, parens, brackets, braces.
Max Length: 64
"""
if not name or not name.strip() or not name.isprintable():
return False

# Block XSS and HTML injection characters
# Allow: ( ) [ ] { } for folder names (e.g. "Work (Private)")
dangerous_chars = set("<>\"'`")
if any(c in dangerous_chars for c in name):
if len(name) > 64:
return False

Check notice

Code scanning / Pylintpython3 (reported by Codacy)

Use lazy % formatting in logging functions Note

Use lazy % formatting in logging functions

# Whitelist: Alphanumeric, space, hyphen, dot, underscore, parens, brackets, braces
if not re.match(r"^[a-zA-Z0-9 \-_.()\[\]{}]+$", name):
return False
Comment on lines +458 to 459

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The regular expression ^[a-zA-Z0-9 \-_.()[\]{}]+$ is recompiled on each call to is_valid_folder_name. For better performance, it's recommended to pre-compile this regex at the module level.


return True
Expand All @@ -461,7 +472,7 @@
return False
if not isinstance(data["group"], dict):
log.error(
f"Invalid data from {sanitize_for_log(url)}: 'group' must be an object."

Check warning

Code scanning / Prospector (reported by Codacy)

Use lazy % formatting in logging functions (logging-fstring-interpolation) Warning

Use lazy % formatting in logging functions (logging-fstring-interpolation)
)
return False
if "group" not in data["group"]:
Expand Down Expand Up @@ -627,11 +638,18 @@
try:
data = _api_get(client, f"{API_BASE}/{profile_id}/groups").json()
folders = data.get("body", {}).get("groups", [])
return {
f["group"].strip(): f["PK"]
for f in folders
if f.get("group") and f.get("PK")
}
result = {}
for f in folders:

Check warning

Code scanning / Pylint (reported by Codacy)

Variable name "f" doesn't conform to snake_case naming style Warning

Variable name "f" doesn't conform to snake_case naming style

Check warning

Code scanning / Pylintpython3 (reported by Codacy)

Variable name "f" doesn't conform to snake_case naming style Warning

Variable name "f" doesn't conform to snake_case naming style
name = f.get("group")
pk = f.get("PK")

Check warning

Code scanning / Pylint (reported by Codacy)

Variable name "pk" doesn't conform to snake_case naming style Warning

Variable name "pk" doesn't conform to snake_case naming style

Check warning

Code scanning / Pylintpython3 (reported by Codacy)

Variable name "pk" doesn't conform to snake_case naming style Warning

Variable name "pk" doesn't conform to snake_case naming style
if name and pk:
if validate_resource_id(pk, "Folder PK", log_errors=False):
result[name.strip()] = pk
else:
log.warning(
f"Skipping folder '{sanitize_for_log(name)}' with unsafe PK: {sanitize_for_log(pk)}"

Check warning

Code scanning / Pylint (reported by Codacy)

Line too long (108/100) Warning

Line too long (108/100)

Check warning

Code scanning / Pylintpython3 (reported by Codacy)

Line too long (108/100) Warning

Line too long (108/100)
Comment on lines +646 to +650
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

list_existing_folders passes pk directly into validate_resource_id, but pk comes from JSON and may be non-string (e.g., an int). That will raise TypeError at len(resource_id) / re.match(...) and can crash sync. Convert pk to str before validating and storing it (and keep the returned mapping values as strings to match the Dict[str, str] annotation).

Suggested change
if validate_resource_id(pk, "Folder PK", log_errors=False):
result[name.strip()] = pk
else:
log.warning(
f"Skipping folder '{sanitize_for_log(name)}' with unsafe PK: {sanitize_for_log(pk)}"
pk_str = str(pk)
if validate_resource_id(pk_str, "Folder PK", log_errors=False):
result[name.strip()] = pk_str
else:
log.warning(
f"Skipping folder '{sanitize_for_log(name)}' with unsafe PK: {sanitize_for_log(pk_str)}"

Copilot uses AI. Check for mistakes.
)
return result
except (httpx.HTTPError, KeyError) as e:
log.error(f"Failed to list existing folders: {sanitize_for_log(e)}")
return {}
Expand Down Expand Up @@ -725,7 +743,7 @@
return None

completed = 0
with concurrent.futures.ThreadPoolExecutor() as executor:

Check notice

Code scanning / Pylintpython3 (reported by Codacy)

Use lazy % formatting in logging functions Note

Use lazy % formatting in logging functions
futures = {
executor.submit(_validate_and_fetch, url): url for url in urls_to_process
}
Expand All @@ -746,7 +764,7 @@
log.warning(
f"Failed to pre-fetch {sanitize_for_log(futures[future])}: {e}"
)
# Restore progress bar after warning

Check warning

Code scanning / Prospector (reported by Codacy)

Use lazy % formatting in logging functions (logging-fstring-interpolation) Warning

Use lazy % formatting in logging functions (logging-fstring-interpolation)
render_progress_bar(completed, total, "Warming up cache", prefix="⏳")

if USE_COLORS:
Expand All @@ -759,6 +777,10 @@
def delete_folder(
client: httpx.Client, profile_id: str, name: str, folder_id: str
) -> bool:
if not validate_resource_id(folder_id, "Folder ID to delete"):
log.error(f"Aborting deletion of {sanitize_for_log(name)}: Unsafe Folder ID")

Check warning

Code scanning / Prospector (reported by Codacy)

Use lazy % formatting in logging functions (logging-fstring-interpolation) Warning

Use lazy % formatting in logging functions (logging-fstring-interpolation)

Check notice

Code scanning / Pylintpython3 (reported by Codacy)

Use lazy % formatting in logging functions Note

Use lazy % formatting in logging functions
return False

try:
_api_delete(client, f"{API_BASE}/{profile_id}/groups/{folder_id}")
log.info("Deleted folder %s (ID %s)", sanitize_for_log(name), folder_id)
Expand All @@ -770,44 +792,57 @@
return False


def create_folder(
client: httpx.Client, profile_id: str, name: str, do: int, status: int
) -> Optional[str]:
"""
Create a new folder and return its ID.
Attempts to read ID from response first, then falls back to polling.
"""
try:
# 1. Send the Create Request
response = _api_post(
client,
f"{API_BASE}/{profile_id}/groups",
data={"name": name, "do": do, "status": status},
)

# OPTIMIZATION: Try to grab ID directly from response to avoid the wait loop
try:
resp_data = response.json()
body = resp_data.get("body", {})

# Check if it returned a single group object
if isinstance(body, dict) and "group" in body and "PK" in body["group"]:
pk = body["group"]["PK"]
log.info(
"Created folder %s (ID %s) [Direct]", sanitize_for_log(name), pk
)
return str(pk)
if validate_resource_id(str(pk), "New Folder PK"):

Check warning

Code scanning / Pylint (reported by Codacy)

Unnecessary "else" after "return" Warning

Unnecessary "else" after "return"

Check warning

Code scanning / Prospector (reported by Codacy)

Unnecessary "else" after "return" (no-else-return) Warning

Unnecessary "else" after "return" (no-else-return)

Check warning

Code scanning / Pylintpython3 (reported by Codacy)

Unnecessary "else" after "return" Warning

Unnecessary "else" after "return"
log.info(
"Created folder %s (ID %s) [Direct]", sanitize_for_log(name), pk
)
return str(pk)
else:
log.error(
f"API returned unsafe PK for folder {sanitize_for_log(name)}: {sanitize_for_log(pk)}"

Check warning

Code scanning / Pylint (reported by Codacy)

Line too long (109/100) Warning

Line too long (109/100)

Check warning

Code scanning / Pylintpython3 (reported by Codacy)

Line too long (109/100) Warning

Line too long (109/100)
)
return None
Comment on lines +818 to +827

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This block of code for validating a new folder's PK, logging, and returning is duplicated in this function (here, in the next if block, and in the polling fallback). To improve maintainability and reduce redundancy, consider extracting this logic into a private helper function.

Here's an example of what that helper could look like:

def _validate_and_return_pk(pk: Any, name: str, context: str) -> Optional[str]:
    pk_str = str(pk)
    if validate_resource_id(pk_str, f"{context} Folder PK"):
        log.info(
            "Created folder %s (ID %s) [%s]", sanitize_for_log(name), pk, context
        )
        return pk_str
    else:
        log.error(
            f"API returned unsafe PK for folder {sanitize_for_log(name)}: {sanitize_for_log(pk)}"
        )
        return None


# Check if it returned a list containing our group
if isinstance(body, dict) and "groups" in body:
for grp in body["groups"]:
if grp.get("group") == name:
log.info(
"Created folder %s (ID %s) [Direct]",
sanitize_for_log(name),
grp["PK"],
)
return str(grp["PK"])
pk = grp["PK"]

Check warning

Code scanning / Pylint (reported by Codacy)

Variable name "pk" doesn't conform to snake_case naming style Warning

Variable name "pk" doesn't conform to snake_case naming style

Check warning

Code scanning / Pylintpython3 (reported by Codacy)

Variable name "pk" doesn't conform to snake_case naming style Warning

Variable name "pk" doesn't conform to snake_case naming style
if validate_resource_id(str(pk), "New Folder PK"):

Check warning

Code scanning / Prospector (reported by Codacy)

Unnecessary "else" after "return" (no-else-return) Warning

Unnecessary "else" after "return" (no-else-return)

Check warning

Code scanning / Pylint (reported by Codacy)

Unnecessary "else" after "return" Warning

Unnecessary "else" after "return"

Check warning

Code scanning / Pylintpython3 (reported by Codacy)

Unnecessary "else" after "return" Warning

Unnecessary "else" after "return"
log.info(
"Created folder %s (ID %s) [Direct]",
sanitize_for_log(name),
pk,
)
return str(pk)
else:
log.error(
f"API returned unsafe PK for folder {sanitize_for_log(name)}: {sanitize_for_log(pk)}"

Check warning

Code scanning / Pylintpython3 (reported by Codacy)

Line too long (117/100) Warning

Line too long (117/100)

Check warning

Code scanning / Pylint (reported by Codacy)

Line too long (117/100) Warning

Line too long (117/100)
)

Check notice

Code scanning / Pylintpython3 (reported by Codacy)

Use lazy % formatting in logging functions Note

Use lazy % formatting in logging functions
return None
except Exception as e:
log.debug(f"Could not extract ID from POST response: {e}")

Expand All @@ -819,12 +854,19 @@

for grp in groups:
if grp["group"].strip() == name.strip():
log.info(
"Created folder %s (ID %s) [Polled]",
sanitize_for_log(name),
grp["PK"],
)
return str(grp["PK"])
pk = grp["PK"]

Check warning

Code scanning / Pylintpython3 (reported by Codacy)

Variable name "pk" doesn't conform to snake_case naming style Warning

Variable name "pk" doesn't conform to snake_case naming style

Check warning

Code scanning / Pylint (reported by Codacy)

Variable name "pk" doesn't conform to snake_case naming style Warning

Variable name "pk" doesn't conform to snake_case naming style
if validate_resource_id(str(pk), "Polled Folder PK"):

Check warning

Code scanning / Pylintpython3 (reported by Codacy)

Unnecessary "else" after "return" Warning

Unnecessary "else" after "return"

Check warning

Code scanning / Prospector (reported by Codacy)

Unnecessary "else" after "return" (no-else-return) Warning

Unnecessary "else" after "return" (no-else-return)

Check warning

Code scanning / Pylint (reported by Codacy)

Unnecessary "else" after "return" Warning

Unnecessary "else" after "return"
log.info(
"Created folder %s (ID %s) [Polled]",
sanitize_for_log(name),
pk,
)
return str(pk)
else:
log.error(

Check notice

Code scanning / Pylintpython3 (reported by Codacy)

Use lazy % formatting in logging functions Note

Use lazy % formatting in logging functions
f"API returned unsafe PK for folder {sanitize_for_log(name)}: {sanitize_for_log(pk)}"

Check warning

Code scanning / Pylintpython3 (reported by Codacy)

Line too long (117/100) Warning

Line too long (117/100)

Check warning

Code scanning / Pylint (reported by Codacy)

Line too long (117/100) Warning

Line too long (117/100)
)
return None
except Exception as e:
log.warning(f"Error fetching groups on attempt {attempt}: {e}")

Expand Down
91 changes: 91 additions & 0 deletions tests/test_id_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import pytest

Check warning

Code scanning / Pylintpython3 (reported by Codacy)

Missing module docstring Warning test

Missing module docstring

Check warning

Code scanning / Prospector (reported by Codacy)

Unable to import 'pytest' (import-error) Warning test

Unable to import 'pytest' (import-error)

Check warning

Code scanning / Prospector (reported by Codacy)

Unused import pytest (unused-import) Warning test

Unused import pytest (unused-import)

Check warning

Code scanning / Pylint (reported by Codacy)

Missing module docstring Warning test

Missing module docstring

Check notice

Code scanning / Pylint (reported by Codacy)

Unused import pytest Note test

Unused import pytest

Check notice

Code scanning / Pylintpython3 (reported by Codacy)

Unused import pytest Note test

Unused import pytest
from unittest.mock import MagicMock

Check warning

Code scanning / Pylintpython3 (reported by Codacy)

standard import "from unittest.mock import MagicMock" should be placed before "import pytest" Warning test

standard import "from unittest.mock import MagicMock" should be placed before "import pytest"

Check warning

Code scanning / Pylint (reported by Codacy)

standard import "from unittest.mock import MagicMock" should be placed before "import pytest" Warning test

standard import "from unittest.mock import MagicMock" should be placed before "import pytest"
import main
Comment on lines +1 to +3
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tests/test_id_validation.py has an unused pytest import and the import grouping/order doesn’t follow the repo’s configured linters/formatters (Trunk enables ruff+isort: .trunk/trunk.yaml:21-29). Remove the unused import and/or reorder imports (stdlib before third-party before local) to avoid ruff F401 / isort failures.

Copilot uses AI. Check for mistakes.

def test_validate_resource_id_valid():
"""Test valid resource IDs."""
valid_ids = [
"123",
"abc",
"ABC",
"Folder_1",
"Profile-2",
"valid_id_123-ABC",
"a" * 64, # Max length
]
for rid in valid_ids:
assert main.validate_resource_id(rid) is True, f"Should accept {rid}"

Check notice

Code scanning / Bandit

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

Check notice

Code scanning / Bandit (reported by Codacy)

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

def test_validate_resource_id_invalid_format():
"""Test invalid formats (unsafe chars)."""
invalid_ids = [
"id with space",
"id/slash",
"id\\backslash",
"id.dot",
"id:colon",
"id<script>",
"../../etc/passwd",
"; drop table",
"&",
"$",
]
# Mock log to silence errors during test
original_log = main.log
main.log = MagicMock()
try:
for rid in invalid_ids:
assert main.validate_resource_id(rid) is False, f"Should reject {rid}"

Check notice

Code scanning / Bandit

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

Check notice

Code scanning / Bandit (reported by Codacy)

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.
finally:
main.log = original_log
Comment on lines +33 to +40

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The try...finally block to mock main.log is repeated in test_validate_resource_id_invalid_length. To make the tests cleaner and more maintainable, you could use a pytest fixture to handle mocking the logger.

For example:

@pytest.fixture
def mock_log(monkeypatch):
    """Fixture to mock the main logger."""
    mock = MagicMock()
    monkeypatch.setattr(main, "log", mock)
    return mock

def test_validate_resource_id_invalid_format(mock_log):
    """Test invalid formats (unsafe chars)."""
    invalid_ids = [
        # ...
    ]
    for rid in invalid_ids:
        assert main.validate_resource_id(rid) is False, f"Should reject {rid}"


def test_validate_resource_id_invalid_length():
"""Test invalid length."""
original_log = main.log
main.log = MagicMock()
try:
long_id = "a" * 65
assert main.validate_resource_id(long_id) is False

Check notice

Code scanning / Bandit

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

Check notice

Code scanning / Bandit (reported by Codacy)

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.
finally:
main.log = original_log

def test_validate_resource_id_empty():
"""Test empty ID."""
assert main.validate_resource_id("") is False

Check notice

Code scanning / Bandit

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

Check notice

Code scanning / Bandit (reported by Codacy)

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.
assert main.validate_resource_id(None) is False

Check notice

Code scanning / Bandit

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

Check notice

Code scanning / Bandit (reported by Codacy)

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

def test_is_valid_folder_name_whitelist():
"""Test the stricter whitelist for folder names."""
valid_names = [
"Work",
"Home Network",
"Folder (Private)",
"Use [Brackets]",
"Use {Braces}",
"Hyphen-ated",
"Under_score",
"Dot.Name",
"123456",
]
for name in valid_names:
assert main.is_valid_folder_name(name) is True, f"Should accept {name}"

Check notice

Code scanning / Bandit

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

Check notice

Code scanning / Bandit (reported by Codacy)

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

invalid_names = [
"Folder with <",
"Folder with >",
"Folder with '",
"Folder with \"",
"Folder with `",
"Folder with ;", # New restriction
"Folder with &", # New restriction
"Folder with |", # New restriction
"Folder with $", # New restriction
"Folder with \\", # New restriction
]
for name in invalid_names:
assert main.is_valid_folder_name(name) is False, f"Should reject {name}"

Check notice

Code scanning / Bandit

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

Check notice

Code scanning / Bandit (reported by Codacy)

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

def test_is_valid_folder_name_length():
"""Test folder name length limit."""
long_name = "a" * 65
assert main.is_valid_folder_name(long_name) is False

Check notice

Code scanning / Bandit

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

Check notice

Code scanning / Bandit (reported by Codacy)

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.
Loading