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.
- {awsquery-0.2.0 → awsquery-0.3.0}/.devcontainer/devcontainer.json +16 -15
- {awsquery-0.2.0 → awsquery-0.3.0}/.github/workflows/test.yml +38 -5
- {awsquery-0.2.0 → awsquery-0.3.0}/Makefile +13 -4
- {awsquery-0.2.0 → awsquery-0.3.0}/PKG-INFO +1 -1
- {awsquery-0.2.0 → awsquery-0.3.0}/pyproject.toml +1 -1
- awsquery-0.3.0/pytest.ini +10 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/src/awsquery/cli.py +49 -42
- {awsquery-0.2.0 → awsquery-0.3.0}/src/awsquery/filters.py +6 -3
- {awsquery-0.2.0 → awsquery-0.3.0}/src/awsquery/formatters.py +7 -5
- awsquery-0.3.0/src/awsquery/security.py +120 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/src/awsquery.egg-info/PKG-INFO +1 -1
- {awsquery-0.2.0 → awsquery-0.3.0}/src/awsquery.egg-info/SOURCES.txt +1 -1
- {awsquery-0.2.0 → awsquery-0.3.0}/tests/conftest.py +4 -4
- awsquery-0.3.0/tests/integration/test_autocomplete_resilience.py +363 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/tests/integration/test_end_to_end.py +56 -64
- {awsquery-0.2.0 → awsquery-0.3.0}/tests/integration/test_feature_combinations.py +6 -20
- {awsquery-0.2.0 → awsquery-0.3.0}/tests/integration/test_keys_behavior.py +4 -14
- {awsquery-0.2.0 → awsquery-0.3.0}/tests/integration/test_multi_level.py +2 -2
- {awsquery-0.2.0 → awsquery-0.3.0}/tests/integration/test_session_management.py +3 -13
- {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_cli_arg_processing.py +10 -17
- {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_cli_flags.py +12 -32
- {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_cli_parser.py +14 -36
- {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_cli_session_args.py +7 -16
- awsquery-0.3.0/tests/unit/test_config_loading_simple.py +53 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_critical_mutations.py +2 -54
- {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_default_column_filters.py +1 -43
- {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_filter_matching.py +1 -5
- {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_keys_display_fix.py +2 -6
- awsquery-0.3.0/tests/unit/test_security.py +192 -0
- awsquery-0.3.0/tests/unit/test_security_edge_cases.py +214 -0
- awsquery-0.2.0/pytest.ini +0 -13
- awsquery-0.2.0/src/awsquery/security.py +0 -108
- awsquery-0.2.0/tests/integration/test_autocomplete_resilience.py +0 -141
- awsquery-0.2.0/tests/unit/test_config_loading.py +0 -375
- awsquery-0.2.0/tests/unit/test_security.py +0 -525
- awsquery-0.2.0/tests/unit/test_security_edge_cases.py +0 -190
- {awsquery-0.2.0 → awsquery-0.3.0}/.claude/agents/code-reviewer.md +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/.claude/agents/makefile-optimizer.md +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/.claude/agents/python-infra-automator.md +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/.claude/agents/test-writer.md +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/.claude/settings.local.json +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/.coveragerc +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/.dockerignore +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/.flake8 +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/.github/workflows/quality.yml +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/.github/workflows/release.yml +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/.gitignore +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/CLAUDE.md +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/Dockerfile +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/Dockerfile.prod +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/LICENSE +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/README.md +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/Todo.md +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/docker-compose.yml +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/docs/DOCKER.md +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/docs/mutation-testing.md +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/policy.json +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/requirements.txt +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/setup.cfg +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/src/awsquery/__init__.py +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/src/awsquery/config.py +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/src/awsquery/core.py +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/src/awsquery/default_filters.yaml +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/src/awsquery/policy.json +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/src/awsquery/utils.py +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/src/awsquery.egg-info/dependency_links.txt +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/src/awsquery.egg-info/entry_points.txt +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/src/awsquery.egg-info/requires.txt +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/src/awsquery.egg-info/top_level.txt +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/tests/__init__.py +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/tests/fixtures/__init__.py +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/tests/fixtures/aws_responses.py +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/tests/fixtures/policy_samples.py +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/tests/integration/__init__.py +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/tests/integration/test_multi_level_calls.py +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/__init__.py +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_column_ordering.py +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_core.py +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_core_inference.py +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_core_string_handling.py +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_filter_edge_cases.py +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_filter_implementation.py +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_filter_parameter_extraction.py +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_filters.py +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_filters_pattern_matching.py +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_formatter_edge_cases.py +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_formatters.py +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_formatters_data_processing.py +0 -0
- {awsquery-0.2.0 → awsquery-0.3.0}/tests/unit/test_messaging_enhancements.py +0 -0
- {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": [
|
|
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": [
|
|
40
|
+
"python.testing.pytestArgs": [
|
|
41
|
+
"tests"
|
|
42
|
+
],
|
|
42
43
|
"editor.formatOnSave": true,
|
|
43
|
-
"editor.rulers": [
|
|
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": [
|
|
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
|
-
|
|
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
|
|
58
|
-
if: always()
|
|
91
|
+
- name: Archive coverage results
|
|
59
92
|
uses: actions/upload-artifact@v4
|
|
60
93
|
with:
|
|
61
|
-
name:
|
|
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
|
|
15
|
-
coverage-report lint format format-check type-check security-check ci build
|
|
16
|
-
publish watch-tests version release update-policy validate-policy all ec2-instances s3-buckets
|
|
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
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "awsquery"
|
|
7
|
-
version = "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
|
|
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
|
|
47
|
+
if arg in SIMPLE_FLAGS:
|
|
39
48
|
flags.append(arg)
|
|
40
|
-
elif arg in
|
|
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
|
|
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
|
|
124
|
+
if arg in SIMPLE_FLAGS:
|
|
114
125
|
flags.append(arg)
|
|
115
126
|
i += 1
|
|
116
|
-
elif arg in
|
|
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
|
|
144
|
+
if arg in SIMPLE_FLAGS:
|
|
134
145
|
flags.append(arg)
|
|
135
146
|
i += 1
|
|
136
|
-
elif arg in
|
|
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
|
|
168
|
+
if arg in SIMPLE_FLAGS:
|
|
158
169
|
i += 1
|
|
159
170
|
continue
|
|
160
|
-
if arg in
|
|
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
|
|
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(
|
|
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
|
-
|
|
221
|
-
|
|
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
|
|
232
|
-
|
|
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(
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
390
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
@@ -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/
|
|
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
|
|
511
|
+
def _validate_readonly_workflow(service, action, policy):
|
|
512
512
|
"""Validate security workflow."""
|
|
513
|
-
from awsquery.security import
|
|
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
|
|
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
|
-
"
|
|
538
|
+
"validate_readonly": _validate_readonly_workflow,
|
|
539
539
|
"validate_formatting": _validate_formatting_workflow,
|
|
540
540
|
},
|
|
541
541
|
)()
|