@@ -1736,6 +1736,198 @@ async def test_put_user_policy_allows_service_wildcard_with_scoped_resource():
17361736 assert 'Successfully' in result .message
17371737
17381738
1739+ @pytest .mark .asyncio
1740+ @pytest .mark .parametrize ('tool_name' , ['put_user_policy' , 'put_role_policy' ])
1741+ @pytest .mark .parametrize (
1742+ 'stmt' ,
1743+ [
1744+ {'Effect' : 'Allow' , 'NotAction' : 'iam:*' , 'Resource' : '*' },
1745+ {'Effect' : 'Allow' , 'NotAction' : ['iam:*' , 'sts:*' , 'organizations:*' ], 'Resource' : '*' },
1746+ {'Effect' : 'Allow' , 'NotAction' : 's3:DeleteBucket' , 'Resource' : 'arn:*' },
1747+ {'Effect' : 'Allow' , 'NotAction' : 'iam:*' , 'Resource' : ['arn:aws:s3:::a' , '*' ]},
1748+ {'Effect' : 'Allow' , 'Action' : '*' , 'NotResource' : 'arn:aws:s3:::protected-bucket' },
1749+ {'Effect' : 'Allow' , 'Action' : 'iam:*' , 'NotResource' : ['arn:aws:iam::123:role/a' ]},
1750+ {'Effect' : 'Allow' , 'NotAction' : 'iam:*' , 'NotResource' : 'arn:aws:s3:::a' },
1751+ ],
1752+ )
1753+ async def test_inline_policy_rejects_not_action_not_resource (tool_name , stmt ):
1754+ """NotAction/NotResource are inverse matches and must be treated as broad.
1755+
1756+ Inspecting only Action/Resource let statements such as NotAction 'iam:*' with
1757+ Resource '*' (every non-IAM action, account-wide) or Action '*' with a NotResource
1758+ (every action on everything but one resource) bypass the guard.
1759+ """
1760+ from awslabs .iam_mcp_server import server
1761+
1762+ Context .initialize (readonly = False , require_confirmation = False )
1763+ target = 'role_name' if tool_name == 'put_role_policy' else 'user_name'
1764+
1765+ with patch ('awslabs.iam_mcp_server.server.get_iam_client' ) as mock_get_client :
1766+ mock_client = Mock ()
1767+ mock_get_client .return_value = mock_client
1768+
1769+ with pytest .raises (IamValidationError ) as exc_info :
1770+ await getattr (server , tool_name )(
1771+ ** {target : 'test-principal' },
1772+ policy_name = 'bad-policy' ,
1773+ policy_document = {'Version' : '2012-10-17' , 'Statement' : [stmt ]},
1774+ confirmed = True ,
1775+ )
1776+ assert 'overly broad Action' in str (exc_info .value )
1777+ getattr (mock_client , tool_name ).assert_not_called ()
1778+
1779+
1780+ @pytest .mark .asyncio
1781+ @pytest .mark .parametrize (
1782+ 'stmt' ,
1783+ [
1784+ {'Effect' : 'Allow' , 'NotAction' : 's3:DeleteObject' , 'Resource' : 'arn:aws:s3:::b/*' },
1785+ {'Effect' : 'Allow' , 'Action' : 's3:GetObject' , 'NotResource' : 'arn:aws:s3:::secret/*' },
1786+ {'Effect' : 'Deny' , 'NotAction' : 'iam:*' , 'Resource' : '*' },
1787+ {'Effect' : 'Deny' , 'Action' : '*' , 'NotResource' : 'arn:aws:s3:::a' },
1788+ ],
1789+ )
1790+ async def test_inline_policy_allows_scoped_not_action_not_resource (stmt ):
1791+ """NotAction/NotResource are allowed when the other half is scoped, or in Deny statements."""
1792+ from awslabs .iam_mcp_server .server import put_user_policy
1793+
1794+ Context .initialize (readonly = False , require_confirmation = False )
1795+
1796+ with patch ('awslabs.iam_mcp_server.server.get_iam_client' ) as mock_get_client :
1797+ mock_get_client .return_value = Mock ()
1798+
1799+ result = await put_user_policy (
1800+ user_name = 'test-user' ,
1801+ policy_name = 'scoped-policy' ,
1802+ policy_document = {'Version' : '2012-10-17' , 'Statement' : [stmt ]},
1803+ confirmed = True ,
1804+ )
1805+ assert 'Successfully' in result .message
1806+
1807+
1808+ @pytest .mark .asyncio
1809+ @pytest .mark .parametrize ('tool_name' , ['put_user_policy' , 'put_role_policy' ])
1810+ @pytest .mark .parametrize (
1811+ 'stmt' ,
1812+ [
1813+ # Exact reproduction from the report.
1814+ {
1815+ 'Effect' : 'Allow' ,
1816+ 'Action' : ['iam:CreateAccessKey' , 'iam:AttachUserPolicy' , 'iam:PutUserPolicy' ],
1817+ 'Resource' : '*' ,
1818+ },
1819+ {'Effect' : 'Allow' , 'Action' : 'iam:PutRolePolicy' , 'Resource' : '*' },
1820+ {'Effect' : 'Allow' , 'Action' : 'iam:AddUserToGroup' , 'Resource' : '*' },
1821+ {'Effect' : 'Allow' , 'Action' : 'iam:CreateLoginProfile' , 'Resource' : '*' },
1822+ {'Effect' : 'Allow' , 'Action' : 'iam:PassRole' , 'Resource' : '*' },
1823+ # IAM action names are case-insensitive.
1824+ {'Effect' : 'Allow' , 'Action' : 'IAM:createAccessKey' , 'Resource' : '*' },
1825+ # Partial wildcards that expand to escalation actions.
1826+ {'Effect' : 'Allow' , 'Action' : 'iam:Put*' , 'Resource' : '*' },
1827+ {'Effect' : 'Allow' , 'Action' : 'iam:*Policy' , 'Resource' : 'arn:aws:iam::*:*' },
1828+ {'Effect' : 'Allow' , 'Action' : 'iam:Create?ccessKey' , 'Resource' : '*' },
1829+ # Mixed with benign actions/resources in the same statement.
1830+ {
1831+ 'Effect' : 'Allow' ,
1832+ 'Action' : ['s3:GetObject' , 'iam:CreatePolicyVersion' ],
1833+ 'Resource' : ['arn:aws:s3:::bucket/*' , '*' ],
1834+ },
1835+ # Every principal of a type is as broad as '*' for escalation.
1836+ {
1837+ 'Effect' : 'Allow' ,
1838+ 'Action' : 'iam:CreateAccessKey' ,
1839+ 'Resource' : 'arn:aws:iam::123456789012:user/*' ,
1840+ },
1841+ {'Effect' : 'Allow' , 'Action' : 'iam:*' , 'Resource' : 'arn:aws-us-gov:iam::*:role/*' },
1842+ {'Effect' : 'Allow' , 'Action' : '*' , 'Resource' : 'arn:aws:iam::123456789012:role/*' },
1843+ # NotAction that doesn't exclude the escalation actions, on every role.
1844+ {'Effect' : 'Allow' , 'NotAction' : 's3:*' , 'Resource' : 'arn:aws:iam::123:role/*' },
1845+ {
1846+ 'Effect' : 'Allow' ,
1847+ 'NotAction' : ['s3:*' , 'iam:CreateAccessKey' ],
1848+ 'Resource' : 'arn:aws:iam::123:role/*' ,
1849+ },
1850+ # Escalation action with NotResource.
1851+ {'Effect' : 'Allow' , 'Action' : 'iam:UpdateAssumeRolePolicy' , 'NotResource' : 'x' },
1852+ ],
1853+ )
1854+ async def test_inline_policy_rejects_privilege_escalation_actions (tool_name , stmt ):
1855+ """Named IAM privesc actions on a broad Resource are equivalent to 'iam:*'.
1856+
1857+ Recognizing only '*' and 'service:*' let canonical escalation actions such as
1858+ iam:CreateAccessKey or iam:AttachUserPolicy with Resource '*' bypass the guard.
1859+ """
1860+ from awslabs .iam_mcp_server import server
1861+
1862+ Context .initialize (readonly = False , require_confirmation = False )
1863+ target = 'role_name' if tool_name == 'put_role_policy' else 'user_name'
1864+
1865+ with patch ('awslabs.iam_mcp_server.server.get_iam_client' ) as mock_get_client :
1866+ mock_client = Mock ()
1867+ mock_get_client .return_value = mock_client
1868+
1869+ with pytest .raises (IamValidationError ) as exc_info :
1870+ await getattr (server , tool_name )(
1871+ ** {target : 'test-principal' },
1872+ policy_name = 'bad-policy' ,
1873+ policy_document = {'Version' : '2012-10-17' , 'Statement' : [stmt ]},
1874+ confirmed = True ,
1875+ )
1876+ assert 'privilege-escalation' in str (exc_info .value )
1877+ getattr (mock_client , tool_name ).assert_not_called ()
1878+
1879+
1880+ @pytest .mark .asyncio
1881+ @pytest .mark .parametrize (
1882+ 'stmt' ,
1883+ [
1884+ # Self-service credential management scoped to the caller.
1885+ {
1886+ 'Effect' : 'Allow' ,
1887+ 'Action' : ['iam:CreateAccessKey' , 'iam:UpdateLoginProfile' ],
1888+ 'Resource' : 'arn:aws:iam::123456789012:user/${aws:username}' ,
1889+ },
1890+ {
1891+ 'Effect' : 'Allow' ,
1892+ 'Action' : 'iam:PassRole' ,
1893+ 'Resource' : 'arn:aws:iam::123456789012:role/my-lambda-role' ,
1894+ },
1895+ # Path-scoped, not every role in the account.
1896+ {
1897+ 'Effect' : 'Allow' ,
1898+ 'Action' : 'iam:AttachRolePolicy' ,
1899+ 'Resource' : 'arn:aws:iam::123456789012:role/app/*' ,
1900+ },
1901+ # Read-only IAM wildcards don't expand to any escalation action.
1902+ {'Effect' : 'Allow' , 'Action' : ['iam:Get*' , 'iam:List*' ], 'Resource' : '*' },
1903+ # NotAction excluding all of IAM, on role ARNs, grants nothing that escalates.
1904+ {'Effect' : 'Allow' , 'NotAction' : 'iam:*' , 'Resource' : 'arn:aws:iam::123:role/*' },
1905+ {
1906+ 'Effect' : 'Allow' ,
1907+ 'NotAction' : ['iam:*' , 'sts:*' ],
1908+ 'Resource' : 'arn:aws:iam::123:role/*' ,
1909+ },
1910+ {'Effect' : 'Deny' , 'Action' : 'iam:CreateAccessKey' , 'Resource' : '*' },
1911+ ],
1912+ )
1913+ async def test_inline_policy_allows_scoped_privilege_escalation_actions (stmt ):
1914+ """Escalation actions on specific principals, and read-only IAM access, are allowed."""
1915+ from awslabs .iam_mcp_server .server import put_user_policy
1916+
1917+ Context .initialize (readonly = False , require_confirmation = False )
1918+
1919+ with patch ('awslabs.iam_mcp_server.server.get_iam_client' ) as mock_get_client :
1920+ mock_get_client .return_value = Mock ()
1921+
1922+ result = await put_user_policy (
1923+ user_name = 'test-user' ,
1924+ policy_name = 'scoped-policy' ,
1925+ policy_document = {'Version' : '2012-10-17' , 'Statement' : [stmt ]},
1926+ confirmed = True ,
1927+ )
1928+ assert 'Successfully' in result .message
1929+
1930+
17391931@pytest .mark .asyncio
17401932async def test_put_user_policy_rejects_wildcard ():
17411933 """Test that put_user_policy rejects Action:* with Resource:*."""
0 commit comments