awsquery 0.2.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (90) hide show
  1. {awsquery-0.2.0 → awsquery-0.3.0}/.devcontainer/devcontainer.json +16 -15
  2. {awsquery-0.2.0 → awsquery-0.3.0}/.github/workflows/test.yml +38 -5
  3. {awsquery-0.2.0 → awsquery-0.3.0}/Makefile +13 -4
  4. {awsquery-0.2.0 → awsquery-0.3.0}/PKG-INFO +1 -1
  5. {awsquery-0.2.0 → awsquery-0.3.0}/pyproject.toml +1 -1
  6. awsquery-0.3.0/pytest.ini +10 -0
  7. {awsquery-0.2.0 → awsquery-0.3.0}/src/awsquery/cli.py +49 -42
  8. {awsquery-0.2.0 → awsquery-0.3.0}/src/awsquery/filters.py +6 -3
  9. {awsquery-0.2.0 → awsquery-0.3.0}/src/awsquery/formatters.py +7 -5
  10. awsquery-0.3.0/src/awsquery/security.py +120 -0
  11. {awsquery-0.2.0 → awsquery-0.3.0}/src/awsquery.egg-info/PKG-INFO +1 -1
  12. {awsquery-0.2.0 → awsquery-0.3.0}/src/awsquery.egg-info/SOURCES.txt +1 -1
  13. {awsquery-0.2.0 → awsquery-0.3.0}/tests/conftest.py +4 -4
  14. awsquery-0.3.0/tests/integration/test_autocomplete_resilience.py +363 -0
  15. {awsquery-0.2.0 → awsquery-0.3.0}/tests/integration/test_end_to_end.py +56 -64
  16. {awsquery-0.2.0 → awsquery-0.3.0}/tests/integration/test_feature_combinations.py +6 -20
  17. {awsquery-0.2.0 → awsquery-0.3.0}/tests/integration/test_keys_behavior.py +4 -14
  18. {awsquery-0.2.0 → awsquery-0.3.0}/tests/integration/test_multi_level.py +2 -2
  19. {awsquery-0.2.0 → awsquery-0.3.0}/tests/integration/test_session_management.py +3 -13
  20. {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_cli_arg_processing.py +10 -17
  21. {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_cli_flags.py +12 -32
  22. {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_cli_parser.py +14 -36
  23. {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_cli_session_args.py +7 -16
  24. awsquery-0.3.0/tests/unit/test_config_loading_simple.py +53 -0
  25. {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_critical_mutations.py +2 -54
  26. {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_default_column_filters.py +1 -43
  27. {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_filter_matching.py +1 -5
  28. {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_keys_display_fix.py +2 -6
  29. awsquery-0.3.0/tests/unit/test_security.py +192 -0
  30. awsquery-0.3.0/tests/unit/test_security_edge_cases.py +214 -0
  31. awsquery-0.2.0/pytest.ini +0 -13
  32. awsquery-0.2.0/src/awsquery/security.py +0 -108
  33. awsquery-0.2.0/tests/integration/test_autocomplete_resilience.py +0 -141
  34. awsquery-0.2.0/tests/unit/test_config_loading.py +0 -375
  35. awsquery-0.2.0/tests/unit/test_security.py +0 -525
  36. awsquery-0.2.0/tests/unit/test_security_edge_cases.py +0 -190
  37. {awsquery-0.2.0 → awsquery-0.3.0}/.claude/agents/code-reviewer.md +0 -0
  38. {awsquery-0.2.0 → awsquery-0.3.0}/.claude/agents/makefile-optimizer.md +0 -0
  39. {awsquery-0.2.0 → awsquery-0.3.0}/.claude/agents/python-infra-automator.md +0 -0
  40. {awsquery-0.2.0 → awsquery-0.3.0}/.claude/agents/test-writer.md +0 -0
  41. {awsquery-0.2.0 → awsquery-0.3.0}/.claude/settings.local.json +0 -0
  42. {awsquery-0.2.0 → awsquery-0.3.0}/.coveragerc +0 -0
  43. {awsquery-0.2.0 → awsquery-0.3.0}/.dockerignore +0 -0
  44. {awsquery-0.2.0 → awsquery-0.3.0}/.flake8 +0 -0
  45. {awsquery-0.2.0 → awsquery-0.3.0}/.github/workflows/quality.yml +0 -0
  46. {awsquery-0.2.0 → awsquery-0.3.0}/.github/workflows/release.yml +0 -0
  47. {awsquery-0.2.0 → awsquery-0.3.0}/.gitignore +0 -0
  48. {awsquery-0.2.0 → awsquery-0.3.0}/CLAUDE.md +0 -0
  49. {awsquery-0.2.0 → awsquery-0.3.0}/Dockerfile +0 -0
  50. {awsquery-0.2.0 → awsquery-0.3.0}/Dockerfile.prod +0 -0
  51. {awsquery-0.2.0 → awsquery-0.3.0}/LICENSE +0 -0
  52. {awsquery-0.2.0 → awsquery-0.3.0}/README.md +0 -0
  53. {awsquery-0.2.0 → awsquery-0.3.0}/Todo.md +0 -0
  54. {awsquery-0.2.0 → awsquery-0.3.0}/docker-compose.yml +0 -0
  55. {awsquery-0.2.0 → awsquery-0.3.0}/docs/DOCKER.md +0 -0
  56. {awsquery-0.2.0 → awsquery-0.3.0}/docs/mutation-testing.md +0 -0
  57. {awsquery-0.2.0 → awsquery-0.3.0}/policy.json +0 -0
  58. {awsquery-0.2.0 → awsquery-0.3.0}/requirements.txt +0 -0
  59. {awsquery-0.2.0 → awsquery-0.3.0}/setup.cfg +0 -0
  60. {awsquery-0.2.0 → awsquery-0.3.0}/src/awsquery/__init__.py +0 -0
  61. {awsquery-0.2.0 → awsquery-0.3.0}/src/awsquery/config.py +0 -0
  62. {awsquery-0.2.0 → awsquery-0.3.0}/src/awsquery/core.py +0 -0
  63. {awsquery-0.2.0 → awsquery-0.3.0}/src/awsquery/default_filters.yaml +0 -0
  64. {awsquery-0.2.0 → awsquery-0.3.0}/src/awsquery/policy.json +0 -0
  65. {awsquery-0.2.0 → awsquery-0.3.0}/src/awsquery/utils.py +0 -0
  66. {awsquery-0.2.0 → awsquery-0.3.0}/src/awsquery.egg-info/dependency_links.txt +0 -0
  67. {awsquery-0.2.0 → awsquery-0.3.0}/src/awsquery.egg-info/entry_points.txt +0 -0
  68. {awsquery-0.2.0 → awsquery-0.3.0}/src/awsquery.egg-info/requires.txt +0 -0
  69. {awsquery-0.2.0 → awsquery-0.3.0}/src/awsquery.egg-info/top_level.txt +0 -0
  70. {awsquery-0.2.0 → awsquery-0.3.0}/tests/__init__.py +0 -0
  71. {awsquery-0.2.0 → awsquery-0.3.0}/tests/fixtures/__init__.py +0 -0
  72. {awsquery-0.2.0 → awsquery-0.3.0}/tests/fixtures/aws_responses.py +0 -0
  73. {awsquery-0.2.0 → awsquery-0.3.0}/tests/fixtures/policy_samples.py +0 -0
  74. {awsquery-0.2.0 → awsquery-0.3.0}/tests/integration/__init__.py +0 -0
  75. {awsquery-0.2.0 → awsquery-0.3.0}/tests/integration/test_multi_level_calls.py +0 -0
  76. {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/__init__.py +0 -0
  77. {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_column_ordering.py +0 -0
  78. {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_core.py +0 -0
  79. {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_core_inference.py +0 -0
  80. {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_core_string_handling.py +0 -0
  81. {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_filter_edge_cases.py +0 -0
  82. {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_filter_implementation.py +0 -0
  83. {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_filter_parameter_extraction.py +0 -0
  84. {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_filters.py +0 -0
  85. {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_filters_pattern_matching.py +0 -0
  86. {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_formatter_edge_cases.py +0 -0
  87. {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_formatters.py +0 -0
  88. {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_formatters_data_processing.py +0 -0
  89. {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_messaging_enhancements.py +0 -0
  90. {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_tags_transformation.py +0 -0
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "name": "AWSQuery Development",
3
3
  "image": "mcr.microsoft.com/devcontainers/python:3.11-bullseye",
4
-
5
4
  "features": {
6
5
  "ghcr.io/anthropics/devcontainer-features/claude-code:1.0.5": {},
7
6
  "ghcr.io/devcontainers/features/aws-cli:1": {
@@ -10,10 +9,8 @@
10
9
  "ghcr.io/devcontainers/features/git:1": {
11
10
  "ppa": true,
12
11
  "version": "latest"
13
- },
14
- "ghcr.io/devcontainers-contrib/features/pre-commit:2": {}
12
+ }
15
13
  },
16
-
17
14
  "customizations": {
18
15
  "vscode": {
19
16
  "extensions": [
@@ -34,30 +31,37 @@
34
31
  "python.linting.enabled": true,
35
32
  "python.linting.pylintEnabled": true,
36
33
  "python.formatting.provider": "black",
37
- "python.formatting.blackArgs": ["--line-length=100"],
34
+ "python.formatting.blackArgs": [
35
+ "--line-length=100"
36
+ ],
38
37
  "python.linting.mypyEnabled": true,
39
38
  "python.testing.pytestEnabled": true,
40
39
  "python.testing.unittestEnabled": false,
41
- "python.testing.pytestArgs": ["tests"],
40
+ "python.testing.pytestArgs": [
41
+ "tests"
42
+ ],
42
43
  "editor.formatOnSave": true,
43
- "editor.rulers": [100],
44
+ "editor.rulers": [
45
+ 100
46
+ ],
44
47
  "[python]": {
45
48
  "editor.defaultFormatter": "ms-python.black-formatter",
46
49
  "editor.codeActionsOnSave": {
47
50
  "source.organizeImports": "explicit"
48
51
  }
49
52
  },
50
- "isort.args": ["--profile", "black", "--line-length", "100"]
53
+ "isort.args": [
54
+ "--profile",
55
+ "black",
56
+ "--line-length",
57
+ "100"
58
+ ]
51
59
  }
52
60
  }
53
61
  },
54
-
55
62
  "postCreateCommand": "pip install -e '.[dev]' && pre-commit install",
56
-
57
63
  "postStartCommand": "echo 'Welcome to AWSQuery development environment!'",
58
-
59
64
  "remoteUser": "vscode",
60
-
61
65
  "mounts": [
62
66
  {
63
67
  "source": "${localEnv:HOME}/.aws",
@@ -66,15 +70,12 @@
66
70
  "consistency": "cached"
67
71
  }
68
72
  ],
69
-
70
73
  "containerEnv": {
71
74
  "AWS_REGION": "${localEnv:AWS_REGION}",
72
75
  "AWS_PROFILE": "${localEnv:AWS_PROFILE}",
73
76
  "AWS_DEFAULT_REGION": "${localEnv:AWS_DEFAULT_REGION}"
74
77
  },
75
-
76
78
  "forwardPorts": [],
77
-
78
79
  "remoteEnv": {
79
80
  "PYTHONPATH": "/workspaces/awsquery/src:${PYTHONPATH}"
80
81
  }
@@ -38,12 +38,46 @@ jobs:
38
38
  python -m pip install --upgrade pip
39
39
  pip install -e ".[dev]"
40
40
 
41
- - name: Run tests
41
+ - name: Run tests (fast, no coverage)
42
42
  run: |
43
- make test
43
+ # Run tests without coverage for speed on older Python versions
44
+ python -m pytest tests/ -n auto -q
45
+
46
+ - name: Archive test results
47
+ if: failure()
48
+ uses: actions/upload-artifact@v4
49
+ with:
50
+ name: test-results-${{ matrix.python-version }}
51
+ path: |
52
+ .pytest_cache/
53
+
54
+ coverage:
55
+ runs-on: ubuntu-latest
56
+ needs: test # Run after tests pass
57
+ steps:
58
+ - uses: actions/checkout@v5
59
+
60
+ - name: Set up Python 3.12 (latest)
61
+ uses: actions/setup-python@v5
62
+ with:
63
+ python-version: '3.12'
64
+
65
+ - name: Cache pip packages
66
+ uses: actions/cache@v4
67
+ with:
68
+ path: ~/.cache/pip
69
+ key: ${{ runner.os }}-pip-${{ hashFiles('pyproject.toml') }}
70
+ restore-keys: |
71
+ ${{ runner.os }}-pip-
72
+
73
+ - name: Install dependencies
74
+ run: |
75
+ python -m pip install --upgrade pip
76
+ pip install -e ".[dev]"
44
77
 
45
78
  - name: Generate coverage report
46
79
  run: |
80
+ # Run with coverage on latest Python where it's fast
47
81
  make coverage
48
82
 
49
83
  - name: Upload coverage to Codecov
@@ -54,11 +88,10 @@ jobs:
54
88
  name: codecov-umbrella
55
89
  fail_ci_if_error: false
56
90
 
57
- - name: Archive test results
58
- if: always()
91
+ - name: Archive coverage results
59
92
  uses: actions/upload-artifact@v4
60
93
  with:
61
- name: test-results-${{ matrix.python-version }}
94
+ name: coverage-report
62
95
  path: |
63
96
  htmlcov/
64
97
  .coverage
@@ -11,10 +11,10 @@ NC := \033[0m
11
11
  # Default target
12
12
  .DEFAULT_GOAL := help
13
13
 
14
- .PHONY: help clean install-dev test test-unit test-integration test-critical test-slow coverage \
15
- coverage-report lint format format-check type-check security-check ci build publish-test \
16
- publish watch-tests version release update-policy validate-policy all ec2-instances s3-buckets iam-users \
17
- iam-roles lambda-functions cloudformation-stacks dynamodb-tables ec2-volumes \
14
+ .PHONY: help clean install-dev test test-unit test-integration test-critical test-slow test-fast test-unit-fast \
15
+ test-integration-fast coverage coverage-report lint format format-check type-check security-check ci build \
16
+ publish-test publish watch-tests version release update-policy validate-policy all ec2-instances s3-buckets \
17
+ iam-users iam-roles lambda-functions cloudformation-stacks dynamodb-tables ec2-volumes \
18
18
  ec2-security-groups s3-bucket-versioning cloudwatch-alarms route53-zones shell \
19
19
  docker-build docker-clean test-in-docker test-awsquery
20
20
 
@@ -48,6 +48,15 @@ test-integration: ## Run integration tests only
48
48
  test-slow: ## Run slow tests
49
49
  python3 -m pytest tests/ -v -m "slow"
50
50
 
51
+ test-fast: ## Run all tests with parallel execution (optimized for Python 3.8-3.10)
52
+ python3 -m pytest tests/ -n auto -q
53
+
54
+ test-unit-fast: ## Run unit tests with parallel execution (2-5 seconds)
55
+ python3 -m pytest tests/unit/ -n auto -q
56
+
57
+ test-integration-fast: ## Run integration tests with parallel execution
58
+ python3 -m pytest tests/integration/ -n auto -q
59
+
51
60
  test-critical: ## Run critical path tests
52
61
  python3 -m pytest tests/ -v -m "critical"
53
62
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: awsquery
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Advanced CLI tool for querying AWS APIs with flexible filtering and automatic parameter resolution
5
5
  Author-email: Florian Motlik <flo@flomotlik.me>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "awsquery"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Advanced CLI tool for querying AWS APIs with flexible filtering and automatic parameter resolution"
9
9
  readme = "README.md"
10
10
  authors = [{ name = "Florian Motlik", email = "flo@flomotlik.me" }]
@@ -0,0 +1,10 @@
1
+ [pytest]
2
+ minversion = 7.0
3
+ testpaths = tests
4
+ python_files = test_*.py
5
+ python_classes = Test*
6
+ python_functions = test_*
7
+ # Coverage disabled by default for Python 3.8-3.10 performance
8
+ # Use: pytest --cov=src/awsquery --cov-report=term-missing
9
+ # Or: make coverage
10
+ addopts = -ra --durations=0 --tb=short
@@ -24,9 +24,18 @@ from .formatters import (
24
24
  format_table_output,
25
25
  show_keys,
26
26
  )
27
- from .security import action_to_policy_format, load_security_policy, validate_security
27
+ from .security import (
28
+ action_to_policy_format,
29
+ get_service_valid_operations,
30
+ is_readonly_operation,
31
+ validate_readonly,
32
+ )
28
33
  from .utils import create_session, debug_print, get_aws_services, sanitize_input
29
34
 
35
+ # CLI flag constants
36
+ SIMPLE_FLAGS = ["-d", "--debug", "-j", "--json", "-k", "--keys", "--allow-unsafe"]
37
+ VALUE_FLAGS = ["--region", "--profile"]
38
+
30
39
 
31
40
  def _extract_flags_from_args(remaining_args):
32
41
  """Extract CLI flags from remaining arguments."""
@@ -35,9 +44,9 @@ def _extract_flags_from_args(remaining_args):
35
44
  i = 0
36
45
  while i < len(remaining_args):
37
46
  arg = remaining_args[i]
38
- if arg in ["-d", "--debug", "-j", "--json", "-k", "--keys"]:
47
+ if arg in SIMPLE_FLAGS:
39
48
  flags.append(arg)
40
- elif arg in ["--region", "--profile"]:
49
+ elif arg in VALUE_FLAGS:
41
50
  flags.append(arg)
42
51
  # Check for value after the flag
43
52
  if i + 1 < len(remaining_args):
@@ -60,6 +69,8 @@ def _preserve_parsed_flags(args):
60
69
  flags.append("-j")
61
70
  if getattr(args, "keys", False):
62
71
  flags.append("-k")
72
+ if getattr(args, "allow_unsafe", False):
73
+ flags.append("--allow-unsafe")
63
74
  if getattr(args, "region", None):
64
75
  flags.extend(["--region", args.region])
65
76
  if getattr(args, "profile", None):
@@ -96,7 +107,7 @@ def _extract_flag_and_value(args, i):
96
107
  """Extract a flag and optionally its value from args list."""
97
108
  flags = []
98
109
  flags.append(args[i])
99
- if args[i] in ["--region", "--profile"]:
110
+ if args[i] in VALUE_FLAGS:
100
111
  if i + 1 < len(args) and not args[i + 1].startswith("-") and args[i + 1] != "--":
101
112
  flags.append(args[i + 1])
102
113
  return flags, 2 # consumed 2 args
@@ -110,10 +121,10 @@ def _process_remaining_args_after_separator(remaining):
110
121
  i = 0
111
122
  while i < len(remaining):
112
123
  arg = remaining[i]
113
- if arg in ["-d", "--debug", "-j", "--json", "-k", "--keys"]:
124
+ if arg in SIMPLE_FLAGS:
114
125
  flags.append(arg)
115
126
  i += 1
116
- elif arg in ["--region", "--profile"]:
127
+ elif arg in VALUE_FLAGS:
117
128
  extracted, consumed = _extract_flag_and_value(remaining, i)
118
129
  flags.extend(extracted)
119
130
  i += consumed
@@ -130,10 +141,10 @@ def _process_remaining_args(remaining):
130
141
  i = 0
131
142
  while i < len(remaining):
132
143
  arg = remaining[i]
133
- if arg in ["-d", "--debug", "-j", "--json", "-k", "--keys"]:
144
+ if arg in SIMPLE_FLAGS:
134
145
  flags.append(arg)
135
146
  i += 1
136
- elif arg in ["--region", "--profile"]:
147
+ elif arg in VALUE_FLAGS:
137
148
  extracted, consumed = _extract_flag_and_value(remaining, i)
138
149
  flags.extend(extracted)
139
150
  i += consumed
@@ -154,10 +165,10 @@ def _build_filter_argv(args, remaining):
154
165
  i = 0
155
166
  while i < len(remaining):
156
167
  arg = remaining[i]
157
- if arg in ["-d", "--debug", "-j", "--json", "-k", "--keys"]:
168
+ if arg in SIMPLE_FLAGS:
158
169
  i += 1
159
170
  continue
160
- if arg in ["--region", "--profile"]:
171
+ if arg in VALUE_FLAGS:
161
172
  i += 1
162
173
  if i < len(remaining) and not remaining[i].startswith("-"):
163
174
  i += 1
@@ -195,6 +206,8 @@ def action_completer(prefix, parsed_args, **kwargs):
195
206
  if not parsed_args.service:
196
207
  return []
197
208
 
209
+ service = parsed_args.service
210
+
198
211
  try:
199
212
  # Create a client with minimal configuration to get operation names
200
213
  # This doesn't require valid AWS credentials, just the service model
@@ -206,36 +219,29 @@ def action_completer(prefix, parsed_args, **kwargs):
206
219
  session = botocore.session.Session()
207
220
 
208
221
  # Check if service exists in botocore's data
209
- if parsed_args.service not in session.get_available_services():
222
+ if service not in session.get_available_services():
210
223
  return []
211
224
 
212
225
  # Load the service model to get operations
213
- service_model = session.get_service_model(parsed_args.service)
226
+ service_model = session.get_service_model(service)
214
227
  operations = list(service_model.operation_names)
215
228
  finally:
216
229
  # Restore AWS_PROFILE if it existed
217
230
  if old_profile is not None:
218
231
  os.environ["AWS_PROFILE"] = old_profile
219
232
 
220
- try:
221
- allowed_actions = load_security_policy()
222
- except:
223
- # If policy loading fails, allow common read operations
224
- allowed_actions = set()
225
- for op in operations:
226
- if any(op.startswith(prefix) for prefix in ["Describe", "List", "Get"]):
227
- allowed_actions.add(f"{parsed_args.service}:{op}")
233
+ # Filter operations to only show read-only ones in autocomplete
234
+ valid_operations = get_service_valid_operations(service, operations)
228
235
 
236
+ # Convert to CLI format
229
237
  cli_operations = []
230
238
  for op in operations:
231
- if not validate_security(parsed_args.service, op, allowed_actions):
232
- continue
233
-
234
- kebab_case = re.sub("([a-z0-9])([A-Z])", r"\1-\2", op).lower()
235
- cli_operations.append(kebab_case)
239
+ if op in valid_operations:
240
+ kebab_case = re.sub("([a-z0-9])([A-Z])", r"\1-\2", op).lower()
241
+ cli_operations.append(kebab_case)
236
242
 
237
243
  matched_ops = [op for op in cli_operations if op.startswith(prefix)]
238
- return sorted(list(set(matched_ops)))
244
+ return sorted(matched_ops)
239
245
  except Exception:
240
246
  # If we can't get operations, return empty
241
247
  return []
@@ -277,6 +283,11 @@ Examples:
277
283
  ) # pragma: no mutate
278
284
  parser.add_argument("--region", help="AWS region to use for requests") # pragma: no mutate
279
285
  parser.add_argument("--profile", help="AWS profile to use for requests") # pragma: no mutate
286
+ parser.add_argument(
287
+ "--allow-unsafe",
288
+ action="store_true",
289
+ help="Allow potentially unsafe (non-readonly) operations without prompting",
290
+ ) # pragma: no mutate
280
291
 
281
292
  service_arg = parser.add_argument(
282
293
  "service", nargs="?", help="AWS service name"
@@ -315,6 +326,8 @@ Examples:
315
326
  flags.append("-j")
316
327
  if getattr(args, "keys", False):
317
328
  flags.append("-k")
329
+ if getattr(args, "allow_unsafe", False):
330
+ flags.append("--allow-unsafe")
318
331
  if getattr(args, "region", None):
319
332
  flags.extend(["--region", args.region])
320
333
  if getattr(args, "profile", None):
@@ -373,23 +386,17 @@ Examples:
373
386
  value_filters = [sanitize_input(f) for f in value_filters] if value_filters else []
374
387
  column_filters = [sanitize_input(f) for f in column_filters] if column_filters else []
375
388
 
376
- allowed_actions = load_security_policy()
377
-
378
- policy_action = action_to_policy_format(action)
379
-
380
- debug_print(
381
- f"DEBUG: Checking security for service='{service}', "
382
- f"action='{action}', policy_action='{policy_action}'"
383
- ) # pragma: no mutate
384
- debug_print(f"DEBUG: Policy has {len(allowed_actions)} allowed actions") # pragma: no mutate
385
-
386
- if not validate_security(service, policy_action, allowed_actions):
387
- print(f"ERROR: Action {service}:{action} not permitted by security policy", file=sys.stderr)
389
+ # Validate operation safety (only if we have a non-empty action)
390
+ if (
391
+ action is not None
392
+ and action
393
+ and str(action).strip() != "None"
394
+ and not validate_readonly(service, action, allow_unsafe=args.allow_unsafe)
395
+ ):
396
+ print(f"ERROR: Operation {service}:{action} was not allowed", file=sys.stderr)
388
397
  sys.exit(1)
389
- else:
390
- debug_print(
391
- f"DEBUG: Action {service}:{policy_action} IS ALLOWED by security policy"
392
- ) # pragma: no mutate
398
+
399
+ debug_print(f"DEBUG: Operation {service}:{action} validated successfully") # pragma: no mutate
393
400
 
394
401
  # Create session with region/profile if specified
395
402
  session = create_session(region=args.region, profile=args.profile)
@@ -1,6 +1,9 @@
1
1
  """Filtering logic for AWS Query Tool."""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  import sys
6
+ from typing import Dict, List
4
7
 
5
8
  from .formatters import convert_parameter_name, flatten_dict_keys, transform_tags_structure
6
9
  from .utils import debug_print, simplify_key
@@ -85,7 +88,7 @@ def filter_resources(resources, value_filters):
85
88
  transformed = transform_tags_structure(resource)
86
89
  transformed_resources.append(transformed)
87
90
 
88
- filtered: list[dict] = []
91
+ filtered: List[Dict] = []
89
92
  for resource in transformed_resources:
90
93
  flattened = flatten_dict_keys(resource)
91
94
 
@@ -156,7 +159,7 @@ def parse_multi_level_filters_for_mode(argv, mode="single"):
156
159
 
157
160
  first_segment = segments[0] if segments else []
158
161
 
159
- third_segment: list[str]
162
+ third_segment: List[str]
160
163
  if len(segments) == 1:
161
164
  second_segment = []
162
165
  third_segment = []
@@ -185,7 +188,7 @@ def parse_multi_level_filters_for_mode(argv, mode="single"):
185
188
  extra_args.append(arg)
186
189
 
187
190
  if mode == "single":
188
- resource_filters: list[str] = []
191
+ resource_filters: List[str] = []
189
192
 
190
193
  if len(segments) == 1:
191
194
  value_filters = extra_args
@@ -1,6 +1,10 @@
1
1
  """Output formatting for AWS Query Tool."""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  import json
6
+ from collections import OrderedDict
7
+ from typing import Dict, List
4
8
 
5
9
  from tabulate import tabulate
6
10
 
@@ -289,7 +293,7 @@ def format_table_output(resources, column_filters=None):
289
293
  if not selected_keys:
290
294
  return "No matching columns found."
291
295
 
292
- simplified_to_full_keys: dict[str, list[str]] = {}
296
+ simplified_to_full_keys: Dict[str, List[str]] = {}
293
297
  unique_headers_ordered = []
294
298
 
295
299
  for key in selected_keys:
@@ -327,15 +331,13 @@ def format_table_output(resources, column_filters=None):
327
331
 
328
332
  def _process_json_resource_with_filters(resource, column_filters):
329
333
  """Process a single resource with column filters for JSON output."""
330
- from collections import OrderedDict
331
-
332
334
  flat = flatten_dict_keys(resource)
333
335
 
334
336
  # Use filter_columns to apply pattern matching with ! operators
335
337
  filtered_flat = filter_columns(flat, column_filters)
336
338
 
337
339
  # Preserve order by using ordered dictionary
338
- simplified_ordered: OrderedDict[str, list[str]] = OrderedDict()
340
+ simplified_ordered: Dict[str, List[str]] = OrderedDict()
339
341
 
340
342
  # Process keys in the order they appear in filtered_flat
341
343
  for key, value in filtered_flat.items():
@@ -346,7 +348,7 @@ def _process_json_resource_with_filters(resource, column_filters):
346
348
  simplified_ordered[simplified].append(str(value))
347
349
 
348
350
  # Build final filtered dict preserving order
349
- filtered: OrderedDict[str, str] = OrderedDict()
351
+ filtered: Dict[str, str] = OrderedDict()
350
352
  for simplified_key, values in simplified_ordered.items():
351
353
  if not values:
352
354
  continue
@@ -0,0 +1,120 @@
1
+ """Security validation for AWS Query Tool using simple prefix matching."""
2
+
3
+ import sys
4
+ from typing import Optional
5
+
6
+ from .utils import debug_print
7
+
8
+ # Common read-only operation prefixes based on AWS ReadOnly policy analysis
9
+ # These prefixes appear 20+ times across AWS services
10
+ SAFE_READONLY_PREFIXES = [
11
+ "List",
12
+ "Get",
13
+ "Describe",
14
+ "Batch",
15
+ "Search",
16
+ "Query",
17
+ "View",
18
+ "Lookup",
19
+ "Read",
20
+ "Scan",
21
+ "Select",
22
+ "Check",
23
+ "Validate",
24
+ "Test",
25
+ "Preview",
26
+ "Verify",
27
+ "Estimate",
28
+ "Discover",
29
+ "Retrieve",
30
+ "Is",
31
+ "Has",
32
+ "Can",
33
+ ]
34
+
35
+
36
+ def is_readonly_operation(action: str) -> bool:
37
+ """Check if an operation is read-only based on common prefixes."""
38
+ # Convert kebab-case to PascalCase for checking
39
+ if "-" in action:
40
+ parts = action.split("-")
41
+ action = "".join(part.capitalize() for part in parts)
42
+
43
+ # Check if action starts with any safe prefix
44
+ for prefix in SAFE_READONLY_PREFIXES:
45
+ if action.startswith(prefix):
46
+ debug_print(f"DEBUG: Operation {action} matches safe prefix {prefix}")
47
+ return True
48
+
49
+ debug_print(f"DEBUG: Operation {action} does not match any safe prefix")
50
+ return False
51
+
52
+
53
+ def prompt_unsafe_operation(service: str, action: str) -> bool:
54
+ """Prompt user to confirm unsafe operation."""
55
+ print(f"\nWARNING: Operation '{service}:{action}' may not be read-only.", file=sys.stderr)
56
+ print("This operation could potentially modify AWS resources.", file=sys.stderr)
57
+
58
+ while True:
59
+ response = input("Do you want to proceed? (yes/no): ").lower().strip()
60
+ if response in ["yes", "y"]:
61
+ return True
62
+ elif response in ["no", "n"]:
63
+ return False
64
+ else:
65
+ print("Please answer 'yes' or 'no'.", file=sys.stderr)
66
+
67
+
68
+ def validate_readonly(service: str, action: str, allow_unsafe: bool = False) -> bool:
69
+ """Validate if an operation is safe to execute.
70
+
71
+ Args:
72
+ service: AWS service name
73
+ action: Action/operation name
74
+ allow_unsafe: If True, allow all operations without prompting
75
+
76
+ Returns:
77
+ True if operation should proceed, False otherwise
78
+ """
79
+ # If allow_unsafe flag is set, allow everything
80
+ if allow_unsafe:
81
+ debug_print(f"DEBUG: --allow-unsafe flag set, allowing {service}:{action}")
82
+ return True
83
+
84
+ # Check if operation matches read-only prefixes
85
+ if is_readonly_operation(action):
86
+ return True
87
+
88
+ # For non-readonly operations, prompt the user
89
+ return prompt_unsafe_operation(service, action)
90
+
91
+
92
+ # Legacy functions for backward compatibility (will be removed later)
93
+ def load_security_policy():
94
+ """Legacy function - returns empty set to avoid breaking existing code."""
95
+ return set()
96
+
97
+
98
+ def validate_security(service: str, action: str, allowed_actions) -> bool:
99
+ """Legacy function - always returns True to avoid breaking existing code."""
100
+ return True
101
+
102
+
103
+ def action_to_policy_format(action: str) -> str:
104
+ """Convert kebab-case or snake_case action to PascalCase."""
105
+ if "-" in action:
106
+ parts = action.split("-")
107
+ return "".join(part.capitalize() for part in parts)
108
+ elif "_" in action:
109
+ parts = action.split("_")
110
+ return "".join(part.capitalize() for part in parts)
111
+ return action
112
+
113
+
114
+ def get_service_valid_operations(service: str, all_operations: list) -> set:
115
+ """Get operations that match read-only prefixes."""
116
+ valid_ops = set()
117
+ for op in all_operations:
118
+ if is_readonly_operation(op):
119
+ valid_ops.add(op)
120
+ return valid_ops
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: awsquery
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Advanced CLI tool for querying AWS APIs with flexible filtering and automatic parameter resolution
5
5
  Author-email: Florian Motlik <flo@flomotlik.me>
6
6
  License: MIT
@@ -60,7 +60,7 @@ tests/unit/test_cli_flags.py
60
60
  tests/unit/test_cli_parser.py
61
61
  tests/unit/test_cli_session_args.py
62
62
  tests/unit/test_column_ordering.py
63
- tests/unit/test_config_loading.py
63
+ tests/unit/test_config_loading_simple.py
64
64
  tests/unit/test_core.py
65
65
  tests/unit/test_core_inference.py
66
66
  tests/unit/test_core_string_handling.py
@@ -508,13 +508,13 @@ def workflow_validator():
508
508
 
509
509
  return base_cmd, res_filters, val_filters, col_filters
510
510
 
511
- def _validate_security_workflow(service, action, policy):
511
+ def _validate_readonly_workflow(service, action, policy):
512
512
  """Validate security workflow."""
513
- from awsquery.security import validate_security
513
+ from awsquery.security import validate_readonly
514
514
  from awsquery.utils import normalize_action_name
515
515
 
516
516
  normalized = normalize_action_name(action)
517
- assert validate_security(service, action.replace("-", "").title().replace(" ", ""), policy)
517
+ assert validate_readonly(service, action.replace("-", "").title().replace(" ", ""), policy)
518
518
  return normalized
519
519
 
520
520
  def _validate_formatting_workflow(response, filters, expected_content):
@@ -535,7 +535,7 @@ def workflow_validator():
535
535
  (),
536
536
  {
537
537
  "validate_parsing": _validate_parsing_workflow,
538
- "validate_security": _validate_security_workflow,
538
+ "validate_readonly": _validate_readonly_workflow,
539
539
  "validate_formatting": _validate_formatting_workflow,
540
540
  },
541
541
  )()