iam-policy-validator 1.14.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- iam_policy_validator-1.14.0.dist-info/METADATA +782 -0
- iam_policy_validator-1.14.0.dist-info/RECORD +106 -0
- iam_policy_validator-1.14.0.dist-info/WHEEL +4 -0
- iam_policy_validator-1.14.0.dist-info/entry_points.txt +2 -0
- iam_policy_validator-1.14.0.dist-info/licenses/LICENSE +21 -0
- iam_validator/__init__.py +27 -0
- iam_validator/__main__.py +11 -0
- iam_validator/__version__.py +9 -0
- iam_validator/checks/__init__.py +45 -0
- iam_validator/checks/action_condition_enforcement.py +1442 -0
- iam_validator/checks/action_resource_matching.py +472 -0
- iam_validator/checks/action_validation.py +67 -0
- iam_validator/checks/condition_key_validation.py +88 -0
- iam_validator/checks/condition_type_mismatch.py +257 -0
- iam_validator/checks/full_wildcard.py +62 -0
- iam_validator/checks/mfa_condition_check.py +105 -0
- iam_validator/checks/policy_size.py +114 -0
- iam_validator/checks/policy_structure.py +556 -0
- iam_validator/checks/policy_type_validation.py +331 -0
- iam_validator/checks/principal_validation.py +708 -0
- iam_validator/checks/resource_validation.py +135 -0
- iam_validator/checks/sensitive_action.py +438 -0
- iam_validator/checks/service_wildcard.py +98 -0
- iam_validator/checks/set_operator_validation.py +153 -0
- iam_validator/checks/sid_uniqueness.py +146 -0
- iam_validator/checks/trust_policy_validation.py +509 -0
- iam_validator/checks/utils/__init__.py +17 -0
- iam_validator/checks/utils/action_parser.py +149 -0
- iam_validator/checks/utils/policy_level_checks.py +190 -0
- iam_validator/checks/utils/sensitive_action_matcher.py +293 -0
- iam_validator/checks/utils/wildcard_expansion.py +86 -0
- iam_validator/checks/wildcard_action.py +58 -0
- iam_validator/checks/wildcard_resource.py +374 -0
- iam_validator/commands/__init__.py +31 -0
- iam_validator/commands/analyze.py +549 -0
- iam_validator/commands/base.py +48 -0
- iam_validator/commands/cache.py +393 -0
- iam_validator/commands/completion.py +471 -0
- iam_validator/commands/download_services.py +255 -0
- iam_validator/commands/post_to_pr.py +86 -0
- iam_validator/commands/query.py +485 -0
- iam_validator/commands/validate.py +830 -0
- iam_validator/core/__init__.py +13 -0
- iam_validator/core/access_analyzer.py +671 -0
- iam_validator/core/access_analyzer_report.py +640 -0
- iam_validator/core/aws_fetcher.py +29 -0
- iam_validator/core/aws_service/__init__.py +21 -0
- iam_validator/core/aws_service/cache.py +108 -0
- iam_validator/core/aws_service/client.py +205 -0
- iam_validator/core/aws_service/fetcher.py +641 -0
- iam_validator/core/aws_service/parsers.py +149 -0
- iam_validator/core/aws_service/patterns.py +51 -0
- iam_validator/core/aws_service/storage.py +291 -0
- iam_validator/core/aws_service/validators.py +380 -0
- iam_validator/core/check_registry.py +679 -0
- iam_validator/core/cli.py +134 -0
- iam_validator/core/codeowners.py +245 -0
- iam_validator/core/condition_validators.py +626 -0
- iam_validator/core/config/__init__.py +81 -0
- iam_validator/core/config/aws_api.py +35 -0
- iam_validator/core/config/aws_global_conditions.py +160 -0
- iam_validator/core/config/category_suggestions.py +181 -0
- iam_validator/core/config/check_documentation.py +390 -0
- iam_validator/core/config/condition_requirements.py +258 -0
- iam_validator/core/config/config_loader.py +670 -0
- iam_validator/core/config/defaults.py +739 -0
- iam_validator/core/config/principal_requirements.py +421 -0
- iam_validator/core/config/sensitive_actions.py +672 -0
- iam_validator/core/config/service_principals.py +132 -0
- iam_validator/core/config/wildcards.py +127 -0
- iam_validator/core/constants.py +149 -0
- iam_validator/core/diff_parser.py +325 -0
- iam_validator/core/finding_fingerprint.py +131 -0
- iam_validator/core/formatters/__init__.py +27 -0
- iam_validator/core/formatters/base.py +147 -0
- iam_validator/core/formatters/console.py +68 -0
- iam_validator/core/formatters/csv.py +171 -0
- iam_validator/core/formatters/enhanced.py +481 -0
- iam_validator/core/formatters/html.py +672 -0
- iam_validator/core/formatters/json.py +33 -0
- iam_validator/core/formatters/markdown.py +64 -0
- iam_validator/core/formatters/sarif.py +251 -0
- iam_validator/core/ignore_patterns.py +297 -0
- iam_validator/core/ignore_processor.py +309 -0
- iam_validator/core/ignored_findings.py +400 -0
- iam_validator/core/label_manager.py +197 -0
- iam_validator/core/models.py +404 -0
- iam_validator/core/policy_checks.py +220 -0
- iam_validator/core/policy_loader.py +785 -0
- iam_validator/core/pr_commenter.py +780 -0
- iam_validator/core/report.py +942 -0
- iam_validator/integrations/__init__.py +28 -0
- iam_validator/integrations/github_integration.py +1821 -0
- iam_validator/integrations/ms_teams.py +442 -0
- iam_validator/sdk/__init__.py +220 -0
- iam_validator/sdk/arn_matching.py +382 -0
- iam_validator/sdk/context.py +222 -0
- iam_validator/sdk/exceptions.py +48 -0
- iam_validator/sdk/helpers.py +177 -0
- iam_validator/sdk/policy_utils.py +451 -0
- iam_validator/sdk/query_utils.py +454 -0
- iam_validator/sdk/shortcuts.py +283 -0
- iam_validator/utils/__init__.py +35 -0
- iam_validator/utils/cache.py +105 -0
- iam_validator/utils/regex.py +205 -0
- iam_validator/utils/terminal.py +22 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Command-line interface for IAM Policy Validator."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from iam_validator.__version__ import __version__
|
|
10
|
+
from iam_validator.commands import ALL_COMMANDS
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def setup_logging(log_level: str | None = None, verbose: bool = False) -> None:
|
|
14
|
+
"""Setup logging configuration.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
log_level: Log level from CLI argument (debug, info, warning, error, critical)
|
|
18
|
+
verbose: Enable verbose logging (deprecated, use --log-level debug instead)
|
|
19
|
+
|
|
20
|
+
Environment Variables:
|
|
21
|
+
LOG_LEVEL: Set log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
|
22
|
+
|
|
23
|
+
Priority:
|
|
24
|
+
1. --log-level CLI argument (highest priority)
|
|
25
|
+
2. LOG_LEVEL environment variable
|
|
26
|
+
3. --verbose flag (sets DEBUG level)
|
|
27
|
+
4. Default: WARNING (lowest priority)
|
|
28
|
+
"""
|
|
29
|
+
# Check for LOG_LEVEL environment variable
|
|
30
|
+
env_log_level = os.getenv("LOG_LEVEL", "").upper()
|
|
31
|
+
|
|
32
|
+
# Map string to logging level
|
|
33
|
+
level_map = {
|
|
34
|
+
"DEBUG": logging.DEBUG,
|
|
35
|
+
"INFO": logging.INFO,
|
|
36
|
+
"WARNING": logging.WARNING,
|
|
37
|
+
"ERROR": logging.ERROR,
|
|
38
|
+
"CRITICAL": logging.CRITICAL,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Priority: CLI --log-level > LOG_LEVEL env var > --verbose flag > default (WARNING)
|
|
42
|
+
if log_level:
|
|
43
|
+
level = level_map[log_level.upper()]
|
|
44
|
+
elif env_log_level in level_map:
|
|
45
|
+
level = level_map[env_log_level]
|
|
46
|
+
elif verbose:
|
|
47
|
+
level = logging.DEBUG
|
|
48
|
+
else:
|
|
49
|
+
level = logging.WARNING
|
|
50
|
+
|
|
51
|
+
logging.basicConfig(
|
|
52
|
+
level=level,
|
|
53
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
54
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def main() -> int:
|
|
59
|
+
"""Main entry point for the CLI.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Exit code
|
|
63
|
+
"""
|
|
64
|
+
parser = argparse.ArgumentParser(
|
|
65
|
+
description="Validate AWS IAM policies for correctness and security",
|
|
66
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Add version argument
|
|
70
|
+
parser.add_argument(
|
|
71
|
+
"--version",
|
|
72
|
+
action="version",
|
|
73
|
+
version=f"iam-validator {__version__}",
|
|
74
|
+
help="Show version information and exit",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Add global log level argument
|
|
78
|
+
parser.add_argument(
|
|
79
|
+
"--log-level",
|
|
80
|
+
choices=["debug", "info", "warning", "error", "critical"],
|
|
81
|
+
default=None,
|
|
82
|
+
help="Set logging level (default: warning)",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
subparsers = parser.add_subparsers(dest="command", help="Command to run")
|
|
86
|
+
|
|
87
|
+
# Register all commands
|
|
88
|
+
command_map = {}
|
|
89
|
+
for command in ALL_COMMANDS:
|
|
90
|
+
cmd_parser = subparsers.add_parser(
|
|
91
|
+
command.name,
|
|
92
|
+
help=command.help,
|
|
93
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
94
|
+
epilog=command.epilog,
|
|
95
|
+
)
|
|
96
|
+
command.add_arguments(cmd_parser)
|
|
97
|
+
command_map[command.name] = command
|
|
98
|
+
|
|
99
|
+
# Parse arguments
|
|
100
|
+
args = parser.parse_args()
|
|
101
|
+
|
|
102
|
+
if not args.command:
|
|
103
|
+
parser.print_help()
|
|
104
|
+
return 1
|
|
105
|
+
|
|
106
|
+
# Setup logging
|
|
107
|
+
log_level = getattr(args, "log_level", None)
|
|
108
|
+
verbose = getattr(args, "verbose", False)
|
|
109
|
+
setup_logging(log_level, verbose)
|
|
110
|
+
|
|
111
|
+
# Execute command
|
|
112
|
+
try:
|
|
113
|
+
command = command_map[args.command]
|
|
114
|
+
exit_code = asyncio.run(command.execute(args))
|
|
115
|
+
return exit_code
|
|
116
|
+
except KeyboardInterrupt:
|
|
117
|
+
logging.warning("Interrupted by user")
|
|
118
|
+
return 130 # Standard exit code for SIGINT
|
|
119
|
+
except asyncio.CancelledError:
|
|
120
|
+
logging.warning("Operation cancelled")
|
|
121
|
+
return 130
|
|
122
|
+
except FileNotFoundError as e:
|
|
123
|
+
logging.error(f"File not found: {e}")
|
|
124
|
+
return 1
|
|
125
|
+
except PermissionError as e:
|
|
126
|
+
logging.error(f"Permission denied: {e}")
|
|
127
|
+
return 1
|
|
128
|
+
except Exception as e:
|
|
129
|
+
logging.error(f"Unexpected error: {e}", exc_info=True)
|
|
130
|
+
return 1
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
if __name__ == "__main__":
|
|
134
|
+
sys.exit(main())
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""CODEOWNERS file parser for GitHub repositories.
|
|
2
|
+
|
|
3
|
+
This module provides functionality to parse GitHub CODEOWNERS files and
|
|
4
|
+
determine which users/teams own specific files. Used to authorize users
|
|
5
|
+
who can ignore validation findings.
|
|
6
|
+
|
|
7
|
+
Reference: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from functools import lru_cache
|
|
15
|
+
from pathlib import PurePosixPath
|
|
16
|
+
from typing import ClassVar
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(slots=True)
|
|
20
|
+
class CodeOwnerRule:
|
|
21
|
+
"""A single rule from a CODEOWNERS file.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
pattern: File pattern (glob-style, GitHub CODEOWNERS format)
|
|
25
|
+
owners: List of @users and/or @org/teams
|
|
26
|
+
compiled_pattern: Pre-compiled regex for fast matching
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
pattern: str
|
|
30
|
+
owners: list[str]
|
|
31
|
+
compiled_pattern: re.Pattern[str] | None = field(default=None, repr=False)
|
|
32
|
+
|
|
33
|
+
def __post_init__(self) -> None:
|
|
34
|
+
"""Pre-compile the pattern for efficient matching."""
|
|
35
|
+
self.compiled_pattern = _compile_codeowners_pattern(self.pattern)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@lru_cache(maxsize=256)
|
|
39
|
+
def _compile_codeowners_pattern(pattern: str) -> re.Pattern[str]:
|
|
40
|
+
"""Compile a CODEOWNERS pattern to regex with caching.
|
|
41
|
+
|
|
42
|
+
CODEOWNERS patterns follow these rules:
|
|
43
|
+
- Patterns starting with / are relative to repo root
|
|
44
|
+
- Patterns without / match any path containing that component
|
|
45
|
+
- * matches anything except /
|
|
46
|
+
- ** matches anything including /
|
|
47
|
+
- Trailing / matches directories
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
pattern: CODEOWNERS glob pattern
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Compiled regex pattern
|
|
54
|
+
"""
|
|
55
|
+
# Normalize the pattern
|
|
56
|
+
original_pattern = pattern
|
|
57
|
+
pattern = pattern.strip()
|
|
58
|
+
|
|
59
|
+
# Handle leading slash (anchored to root)
|
|
60
|
+
anchored = pattern.startswith("/")
|
|
61
|
+
if anchored:
|
|
62
|
+
pattern = pattern[1:]
|
|
63
|
+
|
|
64
|
+
# Handle trailing slash (directory match)
|
|
65
|
+
is_dir = pattern.endswith("/")
|
|
66
|
+
if is_dir:
|
|
67
|
+
pattern = pattern[:-1]
|
|
68
|
+
|
|
69
|
+
# Escape special regex characters except * and ?
|
|
70
|
+
pattern = re.escape(pattern)
|
|
71
|
+
|
|
72
|
+
# Convert glob patterns to regex
|
|
73
|
+
# ** matches any number of directories
|
|
74
|
+
pattern = pattern.replace(r"\*\*", "<<<DOUBLE_STAR>>>")
|
|
75
|
+
# * matches anything except /
|
|
76
|
+
pattern = pattern.replace(r"\*", "[^/]*")
|
|
77
|
+
# ** -> match anything
|
|
78
|
+
pattern = pattern.replace("<<<DOUBLE_STAR>>>", ".*")
|
|
79
|
+
# ? matches single character except /
|
|
80
|
+
pattern = pattern.replace(r"\?", "[^/]")
|
|
81
|
+
|
|
82
|
+
# Build final regex
|
|
83
|
+
if anchored:
|
|
84
|
+
# Anchored patterns match from repo root
|
|
85
|
+
regex = f"^{pattern}"
|
|
86
|
+
elif "/" in original_pattern.lstrip("/"):
|
|
87
|
+
# Patterns with / in them are implicitly anchored
|
|
88
|
+
regex = f"^{pattern}"
|
|
89
|
+
else:
|
|
90
|
+
# Patterns without / can match anywhere in path
|
|
91
|
+
regex = f"(^|/){pattern}"
|
|
92
|
+
|
|
93
|
+
if is_dir:
|
|
94
|
+
# Directory patterns match the directory and anything under it
|
|
95
|
+
regex += "(/|$)"
|
|
96
|
+
else:
|
|
97
|
+
# File patterns match exactly or as prefix for directories
|
|
98
|
+
regex += "($|/)"
|
|
99
|
+
|
|
100
|
+
return re.compile(regex)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class CodeOwnersParser:
|
|
104
|
+
"""Parser for GitHub CODEOWNERS file format.
|
|
105
|
+
|
|
106
|
+
Parses CODEOWNERS content and provides file-to-owner mapping.
|
|
107
|
+
Uses last-matching-pattern semantics as per GitHub's behavior.
|
|
108
|
+
|
|
109
|
+
Example:
|
|
110
|
+
>>> content = '''
|
|
111
|
+
... # Default owners
|
|
112
|
+
... * @default-team
|
|
113
|
+
... # IAM policies owned by security
|
|
114
|
+
... /policies/**/*.json @security-team @security-lead
|
|
115
|
+
... '''
|
|
116
|
+
>>> parser = CodeOwnersParser(content)
|
|
117
|
+
>>> parser.get_owners_for_file("policies/admin/admin.json")
|
|
118
|
+
['@security-team', '@security-lead']
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
CODEOWNERS_PATHS: ClassVar[list[str]] = [
|
|
122
|
+
"CODEOWNERS",
|
|
123
|
+
".github/CODEOWNERS",
|
|
124
|
+
"docs/CODEOWNERS",
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
def __init__(self, content: str) -> None:
|
|
128
|
+
"""Initialize parser with CODEOWNERS content.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
content: Raw content of CODEOWNERS file
|
|
132
|
+
"""
|
|
133
|
+
self.rules: list[CodeOwnerRule] = []
|
|
134
|
+
self._parse(content)
|
|
135
|
+
|
|
136
|
+
def _parse(self, content: str) -> None:
|
|
137
|
+
"""Parse CODEOWNERS file content.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
content: Raw CODEOWNERS file content
|
|
141
|
+
"""
|
|
142
|
+
for line in content.splitlines():
|
|
143
|
+
line = line.strip()
|
|
144
|
+
|
|
145
|
+
# Skip empty lines and comments
|
|
146
|
+
if not line or line.startswith("#"):
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
# Split into pattern and owners
|
|
150
|
+
parts = line.split()
|
|
151
|
+
if len(parts) >= 2:
|
|
152
|
+
pattern = parts[0]
|
|
153
|
+
owners = parts[1:]
|
|
154
|
+
self.rules.append(CodeOwnerRule(pattern=pattern, owners=owners))
|
|
155
|
+
elif len(parts) == 1:
|
|
156
|
+
# Pattern with no owners (unsets ownership)
|
|
157
|
+
self.rules.append(CodeOwnerRule(pattern=parts[0], owners=[]))
|
|
158
|
+
|
|
159
|
+
def get_owners_for_file(self, file_path: str) -> list[str]:
|
|
160
|
+
"""Get owners for a specific file path.
|
|
161
|
+
|
|
162
|
+
Uses last-matching-pattern semantics as per GitHub's behavior.
|
|
163
|
+
If multiple patterns match, the last one in the file wins.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
file_path: Path to the file (relative to repo root)
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
List of owners for the file, or empty list if no match
|
|
170
|
+
"""
|
|
171
|
+
# Normalize path (remove leading ./ or /)
|
|
172
|
+
file_path = file_path.lstrip("./")
|
|
173
|
+
|
|
174
|
+
# Find all matching rules, last one wins
|
|
175
|
+
owners: list[str] = []
|
|
176
|
+
for rule in self.rules:
|
|
177
|
+
if rule.compiled_pattern and rule.compiled_pattern.search(file_path):
|
|
178
|
+
owners = rule.owners
|
|
179
|
+
|
|
180
|
+
return owners
|
|
181
|
+
|
|
182
|
+
def is_owner(self, username: str, file_path: str) -> bool:
|
|
183
|
+
"""Check if a user is an owner of a file.
|
|
184
|
+
|
|
185
|
+
Note: This only checks direct username matches. For team membership,
|
|
186
|
+
use GitHubIntegration.is_user_codeowner() which resolves teams.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
username: GitHub username (with or without @)
|
|
190
|
+
file_path: Path to the file
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
True if user is directly listed as owner
|
|
194
|
+
"""
|
|
195
|
+
# Normalize username
|
|
196
|
+
username = username.lstrip("@").lower()
|
|
197
|
+
|
|
198
|
+
owners = self.get_owners_for_file(file_path)
|
|
199
|
+
for owner in owners:
|
|
200
|
+
owner = owner.lstrip("@").lower()
|
|
201
|
+
# Direct username match (not team)
|
|
202
|
+
if "/" not in owner and owner == username:
|
|
203
|
+
return True
|
|
204
|
+
|
|
205
|
+
return False
|
|
206
|
+
|
|
207
|
+
def get_teams_for_file(self, file_path: str) -> list[tuple[str, str]]:
|
|
208
|
+
"""Get team owners for a file as (org, team_slug) tuples.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
file_path: Path to the file
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
List of (org, team_slug) tuples
|
|
215
|
+
"""
|
|
216
|
+
owners = self.get_owners_for_file(file_path)
|
|
217
|
+
teams: list[tuple[str, str]] = []
|
|
218
|
+
|
|
219
|
+
for owner in owners:
|
|
220
|
+
owner = owner.lstrip("@")
|
|
221
|
+
if "/" in owner:
|
|
222
|
+
parts = owner.split("/", 1)
|
|
223
|
+
if len(parts) == 2:
|
|
224
|
+
teams.append((parts[0], parts[1]))
|
|
225
|
+
|
|
226
|
+
return teams
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def normalize_path(path: str) -> str:
|
|
230
|
+
"""Normalize a file path for CODEOWNERS matching.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
path: File path (may be absolute or relative)
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
Normalized relative path
|
|
237
|
+
"""
|
|
238
|
+
# Convert to posix-style path
|
|
239
|
+
path = str(PurePosixPath(path))
|
|
240
|
+
# Remove leading ./
|
|
241
|
+
if path.startswith("./"):
|
|
242
|
+
path = path[2:]
|
|
243
|
+
# Remove leading /
|
|
244
|
+
path = path.lstrip("/")
|
|
245
|
+
return path
|