vigilai-scan 0.1.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.
- vigilai_scan-0.1.0/LICENSE +21 -0
- vigilai_scan-0.1.0/PKG-INFO +80 -0
- vigilai_scan-0.1.0/README.md +65 -0
- vigilai_scan-0.1.0/pyproject.toml +23 -0
- vigilai_scan-0.1.0/setup.cfg +4 -0
- vigilai_scan-0.1.0/tests/test_checks.py +151 -0
- vigilai_scan-0.1.0/vigilai_scan/__init__.py +3 -0
- vigilai_scan-0.1.0/vigilai_scan/checks/__init__.py +3 -0
- vigilai_scan-0.1.0/vigilai_scan/checks/iam.py +111 -0
- vigilai_scan-0.1.0/vigilai_scan/checks/kms.py +35 -0
- vigilai_scan-0.1.0/vigilai_scan/checks/logging_monitoring.py +35 -0
- vigilai_scan-0.1.0/vigilai_scan/checks/network.py +94 -0
- vigilai_scan-0.1.0/vigilai_scan/checks/s3.py +86 -0
- vigilai_scan-0.1.0/vigilai_scan/cli.py +60 -0
- vigilai_scan-0.1.0/vigilai_scan/registry.py +86 -0
- vigilai_scan-0.1.0/vigilai_scan/report.py +90 -0
- vigilai_scan-0.1.0/vigilai_scan.egg-info/PKG-INFO +80 -0
- vigilai_scan-0.1.0/vigilai_scan.egg-info/SOURCES.txt +20 -0
- vigilai_scan-0.1.0/vigilai_scan.egg-info/dependency_links.txt +1 -0
- vigilai_scan-0.1.0/vigilai_scan.egg-info/entry_points.txt +2 -0
- vigilai_scan-0.1.0/vigilai_scan.egg-info/requires.txt +4 -0
- vigilai_scan-0.1.0/vigilai_scan.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 VigilAI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vigilai-scan
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Read-only AWS compliance baseline scanner — 15 checks mapped to SOC 2 criteria, with the Terraform fix for every failure
|
|
5
|
+
Author: VigilAI
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: aws,compliance,soc2,security,scanner
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: boto3>=1.28
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# vigilai-scan
|
|
17
|
+
|
|
18
|
+
**Find out in 60 seconds why your AWS account would fail an enterprise security review.**
|
|
19
|
+
|
|
20
|
+
15 read-only checks against the controls that SOC 2 auditors, enterprise procurement teams, and security questionnaires actually ask about — each mapped to its SOC 2 criterion, each with a concrete fix.
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
FAIL LOG-01 A multi-region CloudTrail trail is logging [CRITICAL · SOC2 CC7.2]
|
|
24
|
+
FAIL NET-02 No security group opens SSH/RDP to the world [CRITICAL · SOC2 CC6.6]
|
|
25
|
+
SG(s) with 0.0.0.0/0 on admin ports: sg-0892…, sg-0b37…
|
|
26
|
+
FAIL S3-01 Account-level S3 public access block is on [CRITICAL · SOC2 CC6.6]
|
|
27
|
+
...
|
|
28
|
+
7 passed · 7 failed · 1 warnings
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Run it
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install vigilai-scan # or: pipx run vigilai-scan
|
|
35
|
+
vigilai-scan # uses your default AWS profile
|
|
36
|
+
vigilai-scan --profile prod --region us-west-2
|
|
37
|
+
vigilai-scan --json > report.json # CI-friendly; exits 1 on any FAIL
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**Read-only, local, zero telemetry.** Every AWS call is a `Describe`/`List`/`Get`. Nothing is modified, nothing leaves your machine. The safest way to run it is with the AWS-managed `SecurityAudit` policy. Audit the ~700 lines of source yourself — that's the point of it being open.
|
|
41
|
+
|
|
42
|
+
## What it checks
|
|
43
|
+
|
|
44
|
+
| ID | Check | SOC 2 |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| IAM-01 | Root account MFA | CC6.1 |
|
|
47
|
+
| IAM-02 | No IAM users with console passwords (SSO roles instead) | CC6.2 |
|
|
48
|
+
| IAM-03 | MFA on every human identity | CC6.1 |
|
|
49
|
+
| IAM-04 | No access keys older than 90 days | CC6.2 |
|
|
50
|
+
| IAM-05 | No `AdministratorAccess` attached directly to users | CC6.3 |
|
|
51
|
+
| S3-01 | Account-level public access block | CC6.6 |
|
|
52
|
+
| S3-02 | Default encryption on every bucket | CC6.1 |
|
|
53
|
+
| S3-03 | Per-bucket public access blocks | CC6.6 |
|
|
54
|
+
| NET-01 | Default security groups deny all traffic | CC6.6 |
|
|
55
|
+
| NET-02 | No SSH/RDP open to 0.0.0.0/0 | CC6.6 |
|
|
56
|
+
| NET-03 | VPC flow logs enabled | CC7.3 |
|
|
57
|
+
| NET-04 | Peering routes scoped tighter than /24 | CC6.6 |
|
|
58
|
+
| KMS-01 | Rotation on customer-managed keys | CC6.1 |
|
|
59
|
+
| LOG-01 | Active multi-region CloudTrail | CC7.2 |
|
|
60
|
+
| LOG-02 | EBS encryption-by-default | CC6.1 |
|
|
61
|
+
|
|
62
|
+
## The full baseline checklist
|
|
63
|
+
|
|
64
|
+
The scanner covers what's machine-checkable. The complete ~30-control checklist — including the process controls (access reviews, IR severity levels, vuln SLAs, change management) — is in [CHECKLIST.md](CHECKLIST.md). It's the engineering half of compliance, free under CC BY 4.0.
|
|
65
|
+
|
|
66
|
+
## The kit
|
|
67
|
+
|
|
68
|
+
Every failing check above has a ready-to-apply fix — a Terraform module or hardened template, plus the five corporate policies (Access Control, Change Management, Data Classification, Incident Response, Vulnerability Management) that enterprise reviewers ask for, pre-mapped to the code that enforces them.
|
|
69
|
+
|
|
70
|
+
> **⚡ Compliance Kit — $349 one-time.** Versioned Terraform modules + policies + control-to-code mapping, delivered as a private git repo. [Get it here.](https://YOUR-CHECKOUT-LINK)
|
|
71
|
+
>
|
|
72
|
+
> **🚀 Compliance Sprint — done with you in 14 days.** We run the scan, deploy the fixes into your account, customize the policies, and hand you a filled-out security questionnaire answer bank. [Book a free scan review.](https://calendly.com/banhjosh/30min)
|
|
73
|
+
|
|
74
|
+
## FAQ
|
|
75
|
+
|
|
76
|
+
**Why not just use Prowler / ScoutSuite?** They're excellent and far more exhaustive — hundreds of findings across every service. This is deliberately the opposite: the 15 checks an enterprise security reviewer will actually probe first, with a fix attached to each. Run this to get deal-ready this week; run Prowler when you have a security team.
|
|
77
|
+
|
|
78
|
+
**Will this get me SOC 2 certified?** No tool does. This gets your technical controls to the baseline auditors expect, which is most of the engineering work and the part that blocks deals.
|
|
79
|
+
|
|
80
|
+
**License:** MIT (scanner), CC BY 4.0 (checklist).
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# vigilai-scan
|
|
2
|
+
|
|
3
|
+
**Find out in 60 seconds why your AWS account would fail an enterprise security review.**
|
|
4
|
+
|
|
5
|
+
15 read-only checks against the controls that SOC 2 auditors, enterprise procurement teams, and security questionnaires actually ask about — each mapped to its SOC 2 criterion, each with a concrete fix.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
FAIL LOG-01 A multi-region CloudTrail trail is logging [CRITICAL · SOC2 CC7.2]
|
|
9
|
+
FAIL NET-02 No security group opens SSH/RDP to the world [CRITICAL · SOC2 CC6.6]
|
|
10
|
+
SG(s) with 0.0.0.0/0 on admin ports: sg-0892…, sg-0b37…
|
|
11
|
+
FAIL S3-01 Account-level S3 public access block is on [CRITICAL · SOC2 CC6.6]
|
|
12
|
+
...
|
|
13
|
+
7 passed · 7 failed · 1 warnings
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Run it
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install vigilai-scan # or: pipx run vigilai-scan
|
|
20
|
+
vigilai-scan # uses your default AWS profile
|
|
21
|
+
vigilai-scan --profile prod --region us-west-2
|
|
22
|
+
vigilai-scan --json > report.json # CI-friendly; exits 1 on any FAIL
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**Read-only, local, zero telemetry.** Every AWS call is a `Describe`/`List`/`Get`. Nothing is modified, nothing leaves your machine. The safest way to run it is with the AWS-managed `SecurityAudit` policy. Audit the ~700 lines of source yourself — that's the point of it being open.
|
|
26
|
+
|
|
27
|
+
## What it checks
|
|
28
|
+
|
|
29
|
+
| ID | Check | SOC 2 |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| IAM-01 | Root account MFA | CC6.1 |
|
|
32
|
+
| IAM-02 | No IAM users with console passwords (SSO roles instead) | CC6.2 |
|
|
33
|
+
| IAM-03 | MFA on every human identity | CC6.1 |
|
|
34
|
+
| IAM-04 | No access keys older than 90 days | CC6.2 |
|
|
35
|
+
| IAM-05 | No `AdministratorAccess` attached directly to users | CC6.3 |
|
|
36
|
+
| S3-01 | Account-level public access block | CC6.6 |
|
|
37
|
+
| S3-02 | Default encryption on every bucket | CC6.1 |
|
|
38
|
+
| S3-03 | Per-bucket public access blocks | CC6.6 |
|
|
39
|
+
| NET-01 | Default security groups deny all traffic | CC6.6 |
|
|
40
|
+
| NET-02 | No SSH/RDP open to 0.0.0.0/0 | CC6.6 |
|
|
41
|
+
| NET-03 | VPC flow logs enabled | CC7.3 |
|
|
42
|
+
| NET-04 | Peering routes scoped tighter than /24 | CC6.6 |
|
|
43
|
+
| KMS-01 | Rotation on customer-managed keys | CC6.1 |
|
|
44
|
+
| LOG-01 | Active multi-region CloudTrail | CC7.2 |
|
|
45
|
+
| LOG-02 | EBS encryption-by-default | CC6.1 |
|
|
46
|
+
|
|
47
|
+
## The full baseline checklist
|
|
48
|
+
|
|
49
|
+
The scanner covers what's machine-checkable. The complete ~30-control checklist — including the process controls (access reviews, IR severity levels, vuln SLAs, change management) — is in [CHECKLIST.md](CHECKLIST.md). It's the engineering half of compliance, free under CC BY 4.0.
|
|
50
|
+
|
|
51
|
+
## The kit
|
|
52
|
+
|
|
53
|
+
Every failing check above has a ready-to-apply fix — a Terraform module or hardened template, plus the five corporate policies (Access Control, Change Management, Data Classification, Incident Response, Vulnerability Management) that enterprise reviewers ask for, pre-mapped to the code that enforces them.
|
|
54
|
+
|
|
55
|
+
> **⚡ Compliance Kit — $349 one-time.** Versioned Terraform modules + policies + control-to-code mapping, delivered as a private git repo. [Get it here.](https://YOUR-CHECKOUT-LINK)
|
|
56
|
+
>
|
|
57
|
+
> **🚀 Compliance Sprint — done with you in 14 days.** We run the scan, deploy the fixes into your account, customize the policies, and hand you a filled-out security questionnaire answer bank. [Book a free scan review.](https://calendly.com/banhjosh/30min)
|
|
58
|
+
|
|
59
|
+
## FAQ
|
|
60
|
+
|
|
61
|
+
**Why not just use Prowler / ScoutSuite?** They're excellent and far more exhaustive — hundreds of findings across every service. This is deliberately the opposite: the 15 checks an enterprise security reviewer will actually probe first, with a fix attached to each. Run this to get deal-ready this week; run Prowler when you have a security team.
|
|
62
|
+
|
|
63
|
+
**Will this get me SOC 2 certified?** No tool does. This gets your technical controls to the baseline auditors expect, which is most of the engineering work and the part that blocks deals.
|
|
64
|
+
|
|
65
|
+
**License:** MIT (scanner), CC BY 4.0 (checklist).
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vigilai-scan"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Read-only AWS compliance baseline scanner — 15 checks mapped to SOC 2 criteria, with the Terraform fix for every failure"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "VigilAI" }]
|
|
13
|
+
keywords = ["aws", "compliance", "soc2", "security", "scanner"]
|
|
14
|
+
dependencies = ["boto3>=1.28"]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
dev = ["pytest>=7"]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
vigilai-scan = "vigilai_scan.cli:main"
|
|
21
|
+
|
|
22
|
+
[tool.setuptools.packages.find]
|
|
23
|
+
include = ["vigilai_scan*"]
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Unit tests for vigilai-scan checks using stubbed boto3 clients."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
from unittest import mock
|
|
5
|
+
|
|
6
|
+
import boto3
|
|
7
|
+
import pytest
|
|
8
|
+
from botocore.stub import Stubber
|
|
9
|
+
|
|
10
|
+
from vigilai_scan import checks # noqa: F401 (registers checks)
|
|
11
|
+
from vigilai_scan.checks import iam as iam_checks
|
|
12
|
+
from vigilai_scan.checks import network as network_checks
|
|
13
|
+
from vigilai_scan.checks import logging_monitoring as log_checks
|
|
14
|
+
from vigilai_scan.registry import all_checks, PASS, FAIL, WARN, ERROR
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
CRED_REPORT_HEADER = (
|
|
18
|
+
"user,arn,user_creation_time,password_enabled,password_last_used,"
|
|
19
|
+
"password_last_changed,password_next_rotation,mfa_active,"
|
|
20
|
+
"access_key_1_active,access_key_1_last_rotated,"
|
|
21
|
+
"access_key_2_active,access_key_2_last_rotated"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _session_with_stub(service, region="us-east-1"):
|
|
26
|
+
session = boto3.Session(
|
|
27
|
+
aws_access_key_id="testing", aws_secret_access_key="testing", region_name=region)
|
|
28
|
+
client = session.client(service, region_name=region)
|
|
29
|
+
stubber = Stubber(client)
|
|
30
|
+
patcher = mock.patch.object(session, "client", return_value=client)
|
|
31
|
+
return session, stubber, patcher
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _cred_report_session(rows):
|
|
35
|
+
body = CRED_REPORT_HEADER + "\n" + "\n".join(rows)
|
|
36
|
+
session, stubber, patcher = _session_with_stub("iam")
|
|
37
|
+
stubber.add_response("get_credential_report", {
|
|
38
|
+
"Content": body.encode(), "ReportFormat": "text/csv",
|
|
39
|
+
"GeneratedTime": datetime.datetime(2026, 7, 10),
|
|
40
|
+
})
|
|
41
|
+
return session, stubber, patcher
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_root_mfa_fail():
|
|
45
|
+
session, stubber, patcher = _cred_report_session([
|
|
46
|
+
"<root_account>,arn:aws:iam::1:root,2020-01-01T00:00:00Z,true,N/A,N/A,N/A,false,false,N/A,false,N/A",
|
|
47
|
+
])
|
|
48
|
+
with stubber, patcher:
|
|
49
|
+
result = iam_checks.root_mfa(session)[0]
|
|
50
|
+
assert result.status == FAIL
|
|
51
|
+
assert result.check_id == "IAM-01"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_root_mfa_pass():
|
|
55
|
+
session, stubber, patcher = _cred_report_session([
|
|
56
|
+
"<root_account>,arn:aws:iam::1:root,2020-01-01T00:00:00Z,true,N/A,N/A,N/A,true,false,N/A,false,N/A",
|
|
57
|
+
])
|
|
58
|
+
with stubber, patcher:
|
|
59
|
+
result = iam_checks.root_mfa(session)[0]
|
|
60
|
+
assert result.status == PASS
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_console_users_flags_password_users():
|
|
64
|
+
session, stubber, patcher = _cred_report_session([
|
|
65
|
+
"<root_account>,arn,2020-01-01T00:00:00Z,true,N/A,N/A,N/A,true,false,N/A,false,N/A",
|
|
66
|
+
"alice,arn,2021-01-01T00:00:00Z,true,N/A,N/A,N/A,true,false,N/A,false,N/A",
|
|
67
|
+
"ci-bot,arn,2021-01-01T00:00:00Z,false,N/A,N/A,N/A,false,true,2026-07-01T00:00:00Z,false,N/A",
|
|
68
|
+
])
|
|
69
|
+
with stubber, patcher:
|
|
70
|
+
result = iam_checks.console_users(session)[0]
|
|
71
|
+
assert result.status == FAIL
|
|
72
|
+
assert "alice" in result.detail
|
|
73
|
+
assert "ci-bot" not in result.detail
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_stale_access_keys():
|
|
77
|
+
session, stubber, patcher = _cred_report_session([
|
|
78
|
+
"ci-bot,arn,2021-01-01T00:00:00Z,false,N/A,N/A,N/A,false,true,2020-01-01T00:00:00+00:00,false,N/A",
|
|
79
|
+
])
|
|
80
|
+
with stubber, patcher:
|
|
81
|
+
result = iam_checks.stale_access_keys(session)[0]
|
|
82
|
+
assert result.status == FAIL
|
|
83
|
+
assert "ci-bot" in result.detail
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_default_sg_locked_pass_and_fail():
|
|
87
|
+
session, stubber, patcher = _session_with_stub("ec2")
|
|
88
|
+
stubber.add_response("describe_security_groups", {"SecurityGroups": [
|
|
89
|
+
{"GroupId": "sg-clean", "GroupName": "default", "OwnerId": "1",
|
|
90
|
+
"Description": "d", "IpPermissions": [], "IpPermissionsEgress": []},
|
|
91
|
+
]})
|
|
92
|
+
with stubber, patcher:
|
|
93
|
+
assert network_checks.default_sg_locked(session)[0].status == PASS
|
|
94
|
+
|
|
95
|
+
session, stubber, patcher = _session_with_stub("ec2")
|
|
96
|
+
stubber.add_response("describe_security_groups", {"SecurityGroups": [
|
|
97
|
+
{"GroupId": "sg-open", "GroupName": "default", "OwnerId": "1", "Description": "d",
|
|
98
|
+
"IpPermissions": [{"IpProtocol": "-1", "IpRanges": [{"CidrIp": "0.0.0.0/0"}]}],
|
|
99
|
+
"IpPermissionsEgress": []},
|
|
100
|
+
]})
|
|
101
|
+
with stubber, patcher:
|
|
102
|
+
result = network_checks.default_sg_locked(session)[0]
|
|
103
|
+
assert result.status == FAIL
|
|
104
|
+
assert "sg-open" in result.detail
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_world_open_admin_ports():
|
|
108
|
+
session, stubber, patcher = _session_with_stub("ec2")
|
|
109
|
+
stubber.add_response("describe_security_groups", {"SecurityGroups": [
|
|
110
|
+
{"GroupId": "sg-ssh", "GroupName": "web", "OwnerId": "1", "Description": "d",
|
|
111
|
+
"IpPermissions": [{"IpProtocol": "tcp", "FromPort": 22, "ToPort": 22,
|
|
112
|
+
"IpRanges": [{"CidrIp": "0.0.0.0/0"}]}]},
|
|
113
|
+
{"GroupId": "sg-https", "GroupName": "web2", "OwnerId": "1", "Description": "d",
|
|
114
|
+
"IpPermissions": [{"IpProtocol": "tcp", "FromPort": 443, "ToPort": 443,
|
|
115
|
+
"IpRanges": [{"CidrIp": "0.0.0.0/0"}]}]},
|
|
116
|
+
]})
|
|
117
|
+
with stubber, patcher:
|
|
118
|
+
result = network_checks.world_open_admin_ports(session)[0]
|
|
119
|
+
assert result.status == FAIL
|
|
120
|
+
assert "sg-ssh" in result.detail
|
|
121
|
+
assert "sg-https" not in result.detail
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_broad_peering_route_flagged():
|
|
125
|
+
session, stubber, patcher = _session_with_stub("ec2")
|
|
126
|
+
stubber.add_response("describe_route_tables", {"RouteTables": [
|
|
127
|
+
{"RouteTableId": "rtb-1", "Routes": [
|
|
128
|
+
{"DestinationCidrBlock": "10.1.0.0/16", "VpcPeeringConnectionId": "pcx-1"},
|
|
129
|
+
{"DestinationCidrBlock": "10.2.5.0/24", "VpcPeeringConnectionId": "pcx-2"},
|
|
130
|
+
{"DestinationCidrBlock": "0.0.0.0/0", "GatewayId": "igw-1"},
|
|
131
|
+
]},
|
|
132
|
+
]})
|
|
133
|
+
with stubber, patcher:
|
|
134
|
+
result = network_checks.broad_peering_routes(session)[0]
|
|
135
|
+
assert result.status == FAIL
|
|
136
|
+
assert "10.1.0.0/16" in result.detail
|
|
137
|
+
assert "10.2.5.0/24" not in result.detail
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_access_denied_becomes_skip():
|
|
141
|
+
session, stubber, patcher = _session_with_stub("ec2")
|
|
142
|
+
stubber.add_client_error("get_ebs_encryption_by_default",
|
|
143
|
+
service_error_code="UnauthorizedOperation")
|
|
144
|
+
with stubber, patcher:
|
|
145
|
+
result = log_checks.ebs_default_encryption(session)[0]
|
|
146
|
+
assert result.status == ERROR
|
|
147
|
+
assert "SecurityAudit" in result.detail or "AWS error" in result.detail
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_registry_has_15_checks():
|
|
151
|
+
assert len(all_checks()) == 15
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""IAM checks — all built on the credential report plus two List calls."""
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import io
|
|
5
|
+
import time
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
|
|
8
|
+
from ..registry import CheckResult, guard, PASS, FAIL, WARN
|
|
9
|
+
|
|
10
|
+
KEY_MAX_AGE_DAYS = 90
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _credential_report(session) -> list:
|
|
14
|
+
"""Fetch (generating if needed) the IAM credential report as dict rows."""
|
|
15
|
+
iam = session.client("iam")
|
|
16
|
+
for _ in range(10):
|
|
17
|
+
try:
|
|
18
|
+
report = iam.get_credential_report()
|
|
19
|
+
break
|
|
20
|
+
except iam.exceptions.CredentialReportNotPresentException:
|
|
21
|
+
iam.generate_credential_report()
|
|
22
|
+
time.sleep(2)
|
|
23
|
+
else:
|
|
24
|
+
raise RuntimeError("credential report never became available")
|
|
25
|
+
body = report["Content"].decode("utf-8")
|
|
26
|
+
return list(csv.DictReader(io.StringIO(body)))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@guard("IAM-01", "Root account has MFA enabled", "CRITICAL", "CC6.1")
|
|
30
|
+
def root_mfa(session, region=None):
|
|
31
|
+
rows = _credential_report(session)
|
|
32
|
+
root = next((r for r in rows if r["user"] == "<root_account>"), None)
|
|
33
|
+
ok = root is not None and root.get("mfa_active") == "true"
|
|
34
|
+
return [CheckResult(
|
|
35
|
+
"IAM-01", "Root account has MFA enabled", "CRITICAL", "CC6.1",
|
|
36
|
+
PASS if ok else FAIL,
|
|
37
|
+
"" if ok else "The root account can be taken over with a single password.",
|
|
38
|
+
fix="checklist: Access Control — MFA enforced on console",
|
|
39
|
+
)]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@guard("IAM-02", "No IAM users with console passwords (use SSO roles)", "HIGH", "CC6.2")
|
|
43
|
+
def console_users(session, region=None):
|
|
44
|
+
rows = _credential_report(session)
|
|
45
|
+
with_pw = [r["user"] for r in rows
|
|
46
|
+
if r["user"] != "<root_account>" and r.get("password_enabled") == "true"]
|
|
47
|
+
ok = not with_pw
|
|
48
|
+
return [CheckResult(
|
|
49
|
+
"IAM-02", "No IAM users with console passwords (use SSO roles)", "HIGH", "CC6.2",
|
|
50
|
+
PASS if ok else FAIL,
|
|
51
|
+
"" if ok else f"{len(with_pw)} IAM user(s) with passwords: {', '.join(with_pw[:5])}"
|
|
52
|
+
+ ("…" if len(with_pw) > 5 else ""),
|
|
53
|
+
fix="kit: terraform/modules/iam-least-privilege (SSO-federated roles)",
|
|
54
|
+
)]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@guard("IAM-03", "Every human identity has MFA", "HIGH", "CC6.1")
|
|
58
|
+
def users_without_mfa(session, region=None):
|
|
59
|
+
rows = _credential_report(session)
|
|
60
|
+
no_mfa = [r["user"] for r in rows
|
|
61
|
+
if r["user"] != "<root_account>"
|
|
62
|
+
and r.get("password_enabled") == "true"
|
|
63
|
+
and r.get("mfa_active") != "true"]
|
|
64
|
+
ok = not no_mfa
|
|
65
|
+
return [CheckResult(
|
|
66
|
+
"IAM-03", "Every human identity has MFA", "HIGH", "CC6.1",
|
|
67
|
+
PASS if ok else FAIL,
|
|
68
|
+
"" if ok else f"{len(no_mfa)} console user(s) without MFA: {', '.join(no_mfa[:5])}"
|
|
69
|
+
+ ("…" if len(no_mfa) > 5 else ""),
|
|
70
|
+
fix="checklist: Access Control — MFA enforced on console, IdP, and source control",
|
|
71
|
+
)]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@guard("IAM-04", f"No access keys older than {KEY_MAX_AGE_DAYS} days", "MEDIUM", "CC6.2")
|
|
75
|
+
def stale_access_keys(session, region=None):
|
|
76
|
+
rows = _credential_report(session)
|
|
77
|
+
cutoff = datetime.now(timezone.utc) - timedelta(days=KEY_MAX_AGE_DAYS)
|
|
78
|
+
stale = []
|
|
79
|
+
for r in rows:
|
|
80
|
+
for slot in ("1", "2"):
|
|
81
|
+
if r.get(f"access_key_{slot}_active") == "true":
|
|
82
|
+
rotated = r.get(f"access_key_{slot}_last_rotated", "")
|
|
83
|
+
if rotated and rotated not in ("N/A", "not_supported"):
|
|
84
|
+
when = datetime.fromisoformat(rotated.replace("Z", "+00:00"))
|
|
85
|
+
if when < cutoff:
|
|
86
|
+
stale.append(f"{r['user']} (key {slot})")
|
|
87
|
+
ok = not stale
|
|
88
|
+
return [CheckResult(
|
|
89
|
+
"IAM-04", f"No access keys older than {KEY_MAX_AGE_DAYS} days", "MEDIUM", "CC6.2",
|
|
90
|
+
PASS if ok else FAIL,
|
|
91
|
+
"" if ok else f"{len(stale)} stale key(s): {', '.join(stale[:5])}"
|
|
92
|
+
+ ("…" if len(stale) > 5 else ""),
|
|
93
|
+
fix="kit: terraform/modules/iam-least-privilege (OIDC short-lived credentials for CI)",
|
|
94
|
+
)]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@guard("IAM-05", "AdministratorAccess is not attached directly to users", "HIGH", "CC6.3")
|
|
98
|
+
def admin_attached_to_users(session, region=None):
|
|
99
|
+
iam = session.client("iam")
|
|
100
|
+
entities = iam.list_entities_for_policy(
|
|
101
|
+
PolicyArn="arn:aws:iam::aws:policy/AdministratorAccess",
|
|
102
|
+
EntityFilter="User",
|
|
103
|
+
)
|
|
104
|
+
users = [u["UserName"] for u in entities.get("PolicyUsers", [])]
|
|
105
|
+
ok = not users
|
|
106
|
+
return [CheckResult(
|
|
107
|
+
"IAM-05", "AdministratorAccess is not attached directly to users", "HIGH", "CC6.3",
|
|
108
|
+
PASS if ok else FAIL,
|
|
109
|
+
"" if ok else f"Admin users: {', '.join(users[:5])}" + ("…" if len(users) > 5 else ""),
|
|
110
|
+
fix="kit: terraform/modules/iam-least-privilege (MFA-gated break-glass role instead)",
|
|
111
|
+
)]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""KMS checks: rotation on customer-managed keys."""
|
|
2
|
+
|
|
3
|
+
from ..registry import CheckResult, guard, PASS, FAIL, WARN
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@guard("KMS-01", "Customer-managed keys have rotation enabled", "MEDIUM", "CC6.1")
|
|
7
|
+
def key_rotation(session, region=None):
|
|
8
|
+
kms = session.client("kms", region_name=region)
|
|
9
|
+
unrotated, checked = [], 0
|
|
10
|
+
paginator = kms.get_paginator("list_keys")
|
|
11
|
+
for page in paginator.paginate():
|
|
12
|
+
for key in page.get("Keys", []):
|
|
13
|
+
meta = kms.describe_key(KeyId=key["KeyId"])["KeyMetadata"]
|
|
14
|
+
if meta.get("KeyManager") != "CUSTOMER" or meta.get("KeyState") != "Enabled":
|
|
15
|
+
continue
|
|
16
|
+
if meta.get("KeySpec", "SYMMETRIC_DEFAULT") != "SYMMETRIC_DEFAULT":
|
|
17
|
+
continue # rotation only applies to symmetric keys
|
|
18
|
+
checked += 1
|
|
19
|
+
status = kms.get_key_rotation_status(KeyId=key["KeyId"])
|
|
20
|
+
if not status.get("KeyRotationEnabled"):
|
|
21
|
+
unrotated.append(key["KeyId"])
|
|
22
|
+
if checked == 0:
|
|
23
|
+
return [CheckResult(
|
|
24
|
+
"KMS-01", "Customer-managed keys have rotation enabled", "MEDIUM", "CC6.1",
|
|
25
|
+
WARN, "No customer-managed keys found — are you encrypting with CMKs at all?",
|
|
26
|
+
fix="kit: terraform/modules/kms-baseline",
|
|
27
|
+
)]
|
|
28
|
+
ok = not unrotated
|
|
29
|
+
return [CheckResult(
|
|
30
|
+
"KMS-01", "Customer-managed keys have rotation enabled", "MEDIUM", "CC6.1",
|
|
31
|
+
PASS if ok else FAIL,
|
|
32
|
+
"" if ok else f"{len(unrotated)} key(s) without rotation: {', '.join(unrotated[:3])}"
|
|
33
|
+
+ ("…" if len(unrotated) > 3 else ""),
|
|
34
|
+
fix="kit: terraform/modules/kms-baseline (enable_key_rotation = true)",
|
|
35
|
+
)]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Logging & monitoring checks: CloudTrail coverage, EBS default encryption."""
|
|
2
|
+
|
|
3
|
+
from ..registry import CheckResult, guard, PASS, FAIL, WARN
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@guard("LOG-01", "A multi-region CloudTrail trail is logging", "CRITICAL", "CC7.2")
|
|
7
|
+
def cloudtrail_active(session, region=None):
|
|
8
|
+
ct = session.client("cloudtrail", region_name=region)
|
|
9
|
+
trails = ct.describe_trails(includeShadowTrails=True).get("trailList", [])
|
|
10
|
+
active_multiregion = []
|
|
11
|
+
for t in trails:
|
|
12
|
+
if not t.get("IsMultiRegionTrail"):
|
|
13
|
+
continue
|
|
14
|
+
status = ct.get_trail_status(Name=t["TrailARN"])
|
|
15
|
+
if status.get("IsLogging"):
|
|
16
|
+
active_multiregion.append(t["Name"])
|
|
17
|
+
ok = bool(active_multiregion)
|
|
18
|
+
return [CheckResult(
|
|
19
|
+
"LOG-01", "A multi-region CloudTrail trail is logging", "CRITICAL", "CC7.2",
|
|
20
|
+
PASS if ok else FAIL,
|
|
21
|
+
"" if ok else "No active multi-region trail — you cannot answer 'who did what' during an incident.",
|
|
22
|
+
fix="checklist: Incident Response — evidence collection before you need it",
|
|
23
|
+
)]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@guard("LOG-02", "EBS encryption-by-default is on", "MEDIUM", "CC6.1")
|
|
27
|
+
def ebs_default_encryption(session, region=None):
|
|
28
|
+
ec2 = session.client("ec2", region_name=region)
|
|
29
|
+
enabled = ec2.get_ebs_encryption_by_default().get("EbsEncryptionByDefault", False)
|
|
30
|
+
return [CheckResult(
|
|
31
|
+
"LOG-02", "EBS encryption-by-default is on", "MEDIUM", "CC6.1",
|
|
32
|
+
PASS if enabled else FAIL,
|
|
33
|
+
"" if enabled else "New volumes are created unencrypted unless every engineer remembers the flag.",
|
|
34
|
+
fix="checklist: Encryption — one API call fixes this account-wide",
|
|
35
|
+
)]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Network checks: default SGs, world-open admin ports, flow logs, broad peering routes."""
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
|
|
5
|
+
from ..registry import CheckResult, guard, PASS, FAIL, WARN
|
|
6
|
+
|
|
7
|
+
ADMIN_PORTS = {22, 3389}
|
|
8
|
+
PEER_MAX_PREFIX = 24 # peering routes broader than /24 are flagged
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@guard("NET-01", "Default security groups allow no traffic", "HIGH", "CC6.6")
|
|
12
|
+
def default_sg_locked(session, region=None):
|
|
13
|
+
ec2 = session.client("ec2", region_name=region)
|
|
14
|
+
groups = ec2.describe_security_groups(
|
|
15
|
+
Filters=[{"Name": "group-name", "Values": ["default"]}]).get("SecurityGroups", [])
|
|
16
|
+
open_defaults = [g["GroupId"] for g in groups
|
|
17
|
+
if g.get("IpPermissions") or g.get("IpPermissionsEgress")]
|
|
18
|
+
ok = not open_defaults
|
|
19
|
+
return [CheckResult(
|
|
20
|
+
"NET-01", "Default security groups allow no traffic", "HIGH", "CC6.6",
|
|
21
|
+
PASS if ok else FAIL,
|
|
22
|
+
"" if ok else f"Default SG(s) with rules: {', '.join(open_defaults[:5])}"
|
|
23
|
+
+ ("…" if len(open_defaults) > 5 else ""),
|
|
24
|
+
fix="kit: terraform/modules/vpc-baseline (aws_default_security_group with no rules)",
|
|
25
|
+
)]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _rule_hits_admin_port(perm) -> bool:
|
|
29
|
+
if perm.get("IpProtocol") == "-1":
|
|
30
|
+
return True
|
|
31
|
+
from_p, to_p = perm.get("FromPort"), perm.get("ToPort")
|
|
32
|
+
if from_p is None or to_p is None:
|
|
33
|
+
return False
|
|
34
|
+
return any(from_p <= p <= to_p for p in ADMIN_PORTS)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@guard("NET-02", "No security group opens SSH/RDP to the world", "CRITICAL", "CC6.6")
|
|
38
|
+
def world_open_admin_ports(session, region=None):
|
|
39
|
+
ec2 = session.client("ec2", region_name=region)
|
|
40
|
+
offenders = []
|
|
41
|
+
paginator = ec2.get_paginator("describe_security_groups")
|
|
42
|
+
for page in paginator.paginate():
|
|
43
|
+
for g in page.get("SecurityGroups", []):
|
|
44
|
+
for perm in g.get("IpPermissions", []):
|
|
45
|
+
world = any(r.get("CidrIp") == "0.0.0.0/0" for r in perm.get("IpRanges", [])) or \
|
|
46
|
+
any(r.get("CidrIpv6") == "::/0" for r in perm.get("Ipv6Ranges", []))
|
|
47
|
+
if world and _rule_hits_admin_port(perm):
|
|
48
|
+
offenders.append(g["GroupId"])
|
|
49
|
+
break
|
|
50
|
+
ok = not offenders
|
|
51
|
+
return [CheckResult(
|
|
52
|
+
"NET-02", "No security group opens SSH/RDP to the world", "CRITICAL", "CC6.6",
|
|
53
|
+
PASS if ok else FAIL,
|
|
54
|
+
"" if ok else f"SG(s) with 0.0.0.0/0 on admin ports: {', '.join(offenders[:5])}"
|
|
55
|
+
+ ("…" if len(offenders) > 5 else ""),
|
|
56
|
+
fix="checklist: Network — no world-open admin ports; use SSM Session Manager",
|
|
57
|
+
)]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@guard("NET-03", "Every VPC has flow logs enabled", "MEDIUM", "CC7.3")
|
|
61
|
+
def vpc_flow_logs(session, region=None):
|
|
62
|
+
ec2 = session.client("ec2", region_name=region)
|
|
63
|
+
vpcs = {v["VpcId"] for v in ec2.describe_vpcs().get("Vpcs", [])}
|
|
64
|
+
logged = {f["ResourceId"] for f in ec2.describe_flow_logs().get("FlowLogs", [])}
|
|
65
|
+
missing = sorted(vpcs - logged)
|
|
66
|
+
ok = not missing
|
|
67
|
+
return [CheckResult(
|
|
68
|
+
"NET-03", "Every VPC has flow logs enabled", "MEDIUM", "CC7.3",
|
|
69
|
+
PASS if ok else FAIL,
|
|
70
|
+
"" if ok else f"VPC(s) without flow logs: {', '.join(missing[:5])}"
|
|
71
|
+
+ ("…" if len(missing) > 5 else ""),
|
|
72
|
+
fix="kit: terraform/modules/vpc-baseline (aws_flow_log, 90-day retention)",
|
|
73
|
+
)]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@guard("NET-04", f"Peering routes are scoped tighter than /{PEER_MAX_PREFIX}", "HIGH", "CC6.6")
|
|
77
|
+
def broad_peering_routes(session, region=None):
|
|
78
|
+
ec2 = session.client("ec2", region_name=region)
|
|
79
|
+
broad = []
|
|
80
|
+
for table in ec2.describe_route_tables().get("RouteTables", []):
|
|
81
|
+
for route in table.get("Routes", []):
|
|
82
|
+
if route.get("VpcPeeringConnectionId") and route.get("DestinationCidrBlock"):
|
|
83
|
+
net = ipaddress.ip_network(route["DestinationCidrBlock"], strict=False)
|
|
84
|
+
if net.prefixlen < PEER_MAX_PREFIX:
|
|
85
|
+
broad.append(f"{table['RouteTableId']}→{route['DestinationCidrBlock']}")
|
|
86
|
+
ok = not broad
|
|
87
|
+
return [CheckResult(
|
|
88
|
+
"NET-04", f"Peering routes are scoped tighter than /{PEER_MAX_PREFIX}", "HIGH", "CC6.6",
|
|
89
|
+
PASS if ok else FAIL,
|
|
90
|
+
"" if ok else f"Broad peering route(s): {', '.join(broad[:5])}"
|
|
91
|
+
+ ("…" if len(broad) > 5 else "")
|
|
92
|
+
+ " — a compromised peer network can reach everything in these ranges.",
|
|
93
|
+
fix="kit: terraform/modules/vpc-baseline (scoped peer routes, none to data subnets)",
|
|
94
|
+
)]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""S3 checks: account-level public access block, per-bucket encryption and PAB."""
|
|
2
|
+
|
|
3
|
+
from botocore.exceptions import ClientError
|
|
4
|
+
|
|
5
|
+
from ..registry import CheckResult, guard, PASS, FAIL, WARN
|
|
6
|
+
|
|
7
|
+
MAX_BUCKETS = 200 # keep scans fast on large accounts
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@guard("S3-01", "Account-level S3 public access block is fully on", "CRITICAL", "CC6.6")
|
|
11
|
+
def account_public_access_block(session, region=None):
|
|
12
|
+
sts = session.client("sts")
|
|
13
|
+
account_id = sts.get_caller_identity()["Account"]
|
|
14
|
+
s3control = session.client("s3control", region_name=region or "us-east-1")
|
|
15
|
+
try:
|
|
16
|
+
cfg = s3control.get_public_access_block(AccountId=account_id)[
|
|
17
|
+
"PublicAccessBlockConfiguration"]
|
|
18
|
+
ok = all(cfg.get(k) for k in (
|
|
19
|
+
"BlockPublicAcls", "IgnorePublicAcls", "BlockPublicPolicy", "RestrictPublicBuckets"))
|
|
20
|
+
detail = "" if ok else "One or more account-level public access settings are off."
|
|
21
|
+
except ClientError as exc:
|
|
22
|
+
if exc.response["Error"]["Code"] == "NoSuchPublicAccessBlockConfiguration":
|
|
23
|
+
ok, detail = False, "No account-level public access block configured at all."
|
|
24
|
+
else:
|
|
25
|
+
raise
|
|
26
|
+
return [CheckResult(
|
|
27
|
+
"S3-01", "Account-level S3 public access block is fully on", "CRITICAL", "CC6.6",
|
|
28
|
+
PASS if ok else FAIL, detail,
|
|
29
|
+
fix="kit: terraform/modules/kms-baseline (bucket hardening pattern)",
|
|
30
|
+
)]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _bucket_names(session):
|
|
34
|
+
s3 = session.client("s3")
|
|
35
|
+
return [b["Name"] for b in s3.list_buckets().get("Buckets", [])][:MAX_BUCKETS]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@guard("S3-02", "Every bucket has default encryption", "HIGH", "CC6.1")
|
|
39
|
+
def bucket_encryption(session, region=None):
|
|
40
|
+
s3 = session.client("s3")
|
|
41
|
+
unencrypted = []
|
|
42
|
+
for name in _bucket_names(session):
|
|
43
|
+
try:
|
|
44
|
+
s3.get_bucket_encryption(Bucket=name)
|
|
45
|
+
except ClientError as exc:
|
|
46
|
+
if exc.response["Error"]["Code"] == "ServerSideEncryptionConfigurationNotFoundError":
|
|
47
|
+
unencrypted.append(name)
|
|
48
|
+
elif exc.response["Error"]["Code"] in ("AccessDenied", "AccessDeniedException"):
|
|
49
|
+
continue # skip buckets we can't read rather than failing the check
|
|
50
|
+
else:
|
|
51
|
+
raise
|
|
52
|
+
ok = not unencrypted
|
|
53
|
+
return [CheckResult(
|
|
54
|
+
"S3-02", "Every bucket has default encryption", "HIGH", "CC6.1",
|
|
55
|
+
PASS if ok else FAIL,
|
|
56
|
+
"" if ok else f"{len(unencrypted)} unencrypted bucket(s): {', '.join(unencrypted[:5])}"
|
|
57
|
+
+ ("…" if len(unencrypted) > 5 else ""),
|
|
58
|
+
fix="kit: terraform/modules/kms-baseline (SSE-KMS with rotation)",
|
|
59
|
+
)]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@guard("S3-03", "Every bucket has its own public access block", "HIGH", "CC6.6")
|
|
63
|
+
def bucket_public_access_block(session, region=None):
|
|
64
|
+
s3 = session.client("s3")
|
|
65
|
+
missing = []
|
|
66
|
+
for name in _bucket_names(session):
|
|
67
|
+
try:
|
|
68
|
+
cfg = s3.get_public_access_block(Bucket=name)["PublicAccessBlockConfiguration"]
|
|
69
|
+
if not all(cfg.get(k) for k in (
|
|
70
|
+
"BlockPublicAcls", "IgnorePublicAcls", "BlockPublicPolicy", "RestrictPublicBuckets")):
|
|
71
|
+
missing.append(name)
|
|
72
|
+
except ClientError as exc:
|
|
73
|
+
if exc.response["Error"]["Code"] == "NoSuchPublicAccessBlockConfiguration":
|
|
74
|
+
missing.append(name)
|
|
75
|
+
elif exc.response["Error"]["Code"] in ("AccessDenied", "AccessDeniedException"):
|
|
76
|
+
continue
|
|
77
|
+
else:
|
|
78
|
+
raise
|
|
79
|
+
ok = not missing
|
|
80
|
+
return [CheckResult(
|
|
81
|
+
"S3-03", "Every bucket has its own public access block", "HIGH", "CC6.6",
|
|
82
|
+
PASS if ok else FAIL,
|
|
83
|
+
"" if ok else f"{len(missing)} bucket(s) without full PAB: {', '.join(missing[:5])}"
|
|
84
|
+
+ ("…" if len(missing) > 5 else ""),
|
|
85
|
+
fix="kit: terraform/modules/kms-baseline (aws_s3_bucket_public_access_block)",
|
|
86
|
+
)]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""vigilai-scan CLI entry point.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
vigilai-scan # default profile & region
|
|
5
|
+
vigilai-scan --profile staging --region us-west-2
|
|
6
|
+
vigilai-scan --json > report.json # machine-readable, CI-friendly
|
|
7
|
+
|
|
8
|
+
Exit codes: 0 = all clear, 1 = at least one FAIL, 2 = couldn't run.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
import boto3
|
|
15
|
+
from botocore.exceptions import NoCredentialsError, ClientError
|
|
16
|
+
|
|
17
|
+
from . import __version__, checks # noqa: F401 (import registers checks)
|
|
18
|
+
from .registry import all_checks, FAIL
|
|
19
|
+
from .report import render_terminal, render_json, supports_color
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main(argv=None) -> int:
|
|
23
|
+
parser = argparse.ArgumentParser(
|
|
24
|
+
prog="vigilai-scan",
|
|
25
|
+
description="Read-only AWS compliance baseline scanner (15 checks, SOC 2 mapped).",
|
|
26
|
+
)
|
|
27
|
+
parser.add_argument("--profile", help="AWS profile name")
|
|
28
|
+
parser.add_argument("--region", help="AWS region (defaults to profile/env region)")
|
|
29
|
+
parser.add_argument("--json", action="store_true", help="emit JSON instead of the terminal report")
|
|
30
|
+
parser.add_argument("--no-color", action="store_true", help="disable ANSI colors")
|
|
31
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
32
|
+
args = parser.parse_args(argv)
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
session = boto3.Session(profile_name=args.profile, region_name=args.region)
|
|
36
|
+
region = args.region or session.region_name or "us-east-1"
|
|
37
|
+
account = session.client("sts").get_caller_identity()["Account"]
|
|
38
|
+
except NoCredentialsError:
|
|
39
|
+
print("No AWS credentials found. Configure with `aws configure` or set AWS_PROFILE.",
|
|
40
|
+
file=sys.stderr)
|
|
41
|
+
return 2
|
|
42
|
+
except ClientError as exc:
|
|
43
|
+
print(f"Could not authenticate to AWS: {exc}", file=sys.stderr)
|
|
44
|
+
return 2
|
|
45
|
+
|
|
46
|
+
results = []
|
|
47
|
+
for check in all_checks():
|
|
48
|
+
results.extend(check.func(session, region))
|
|
49
|
+
|
|
50
|
+
if args.json:
|
|
51
|
+
print(render_json(results, account, region))
|
|
52
|
+
else:
|
|
53
|
+
color = supports_color() and not args.no_color
|
|
54
|
+
print(render_terminal(results, account, region, color=color))
|
|
55
|
+
|
|
56
|
+
return 1 if any(r.status == FAIL for r in results) else 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
if __name__ == "__main__":
|
|
60
|
+
sys.exit(main())
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Check result model and registry.
|
|
2
|
+
|
|
3
|
+
Every check is a function taking a boto3 Session and returning a list of
|
|
4
|
+
CheckResult. Checks make exclusively read-only (Describe/List/Get) calls.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Callable, List, Optional
|
|
9
|
+
|
|
10
|
+
from botocore.exceptions import ClientError, NoCredentialsError
|
|
11
|
+
|
|
12
|
+
PASS = "PASS"
|
|
13
|
+
FAIL = "FAIL"
|
|
14
|
+
WARN = "WARN"
|
|
15
|
+
ERROR = "ERROR" # couldn't evaluate (usually AccessDenied)
|
|
16
|
+
|
|
17
|
+
SEVERITY_ORDER = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class CheckResult:
|
|
22
|
+
check_id: str
|
|
23
|
+
title: str
|
|
24
|
+
severity: str # CRITICAL / HIGH / MEDIUM / LOW
|
|
25
|
+
soc2: str # e.g. "CC6.1"
|
|
26
|
+
status: str # PASS / FAIL / WARN / ERROR
|
|
27
|
+
detail: str = ""
|
|
28
|
+
fix: str = "" # pointer to the remediation in the kit/checklist
|
|
29
|
+
|
|
30
|
+
def to_dict(self) -> dict:
|
|
31
|
+
return {
|
|
32
|
+
"id": self.check_id,
|
|
33
|
+
"title": self.title,
|
|
34
|
+
"severity": self.severity,
|
|
35
|
+
"soc2": self.soc2,
|
|
36
|
+
"status": self.status,
|
|
37
|
+
"detail": self.detail,
|
|
38
|
+
"fix": self.fix,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Check:
|
|
44
|
+
func: Callable
|
|
45
|
+
name: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_REGISTRY: List[Check] = []
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def register(func: Callable) -> Callable:
|
|
52
|
+
_REGISTRY.append(Check(func=func, name=func.__name__))
|
|
53
|
+
return func
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def all_checks() -> List[Check]:
|
|
57
|
+
return list(_REGISTRY)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def guard(check_id: str, title: str, severity: str, soc2: str):
|
|
61
|
+
"""Wrap a check so auth/permission failures become ERROR results
|
|
62
|
+
instead of crashing the whole scan."""
|
|
63
|
+
|
|
64
|
+
def decorator(func: Callable):
|
|
65
|
+
def wrapper(session, region: Optional[str] = None) -> List[CheckResult]:
|
|
66
|
+
try:
|
|
67
|
+
return func(session, region)
|
|
68
|
+
except NoCredentialsError:
|
|
69
|
+
raise # fatal: handled by the CLI with a clear message
|
|
70
|
+
except ClientError as exc:
|
|
71
|
+
code = exc.response.get("Error", {}).get("Code", "")
|
|
72
|
+
if code in ("AccessDenied", "AccessDeniedException", "UnauthorizedOperation", "AuthorizationError"):
|
|
73
|
+
return [CheckResult(
|
|
74
|
+
check_id=check_id, title=title, severity=severity, soc2=soc2,
|
|
75
|
+
status=ERROR,
|
|
76
|
+
detail="Access denied — attach the AWS-managed SecurityAudit policy to the scanning role.",
|
|
77
|
+
)]
|
|
78
|
+
return [CheckResult(
|
|
79
|
+
check_id=check_id, title=title, severity=severity, soc2=soc2,
|
|
80
|
+
status=ERROR, detail=f"AWS error: {code}",
|
|
81
|
+
)]
|
|
82
|
+
|
|
83
|
+
wrapper.__name__ = func.__name__
|
|
84
|
+
return register(wrapper)
|
|
85
|
+
|
|
86
|
+
return decorator
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Terminal and JSON rendering for scan results."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
from .registry import CheckResult, PASS, FAIL, WARN, ERROR, SEVERITY_ORDER
|
|
8
|
+
|
|
9
|
+
RESET = "\033[0m"
|
|
10
|
+
BOLD = "\033[1m"
|
|
11
|
+
DIM = "\033[2m"
|
|
12
|
+
RED = "\033[31m"
|
|
13
|
+
GREEN = "\033[32m"
|
|
14
|
+
YELLOW = "\033[33m"
|
|
15
|
+
|
|
16
|
+
STATUS_STYLE = {
|
|
17
|
+
PASS: (GREEN, "PASS"),
|
|
18
|
+
FAIL: (RED, "FAIL"),
|
|
19
|
+
WARN: (YELLOW, "WARN"),
|
|
20
|
+
ERROR: (DIM, "SKIP"),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _c(text: str, *styles: str, color: bool = True) -> str:
|
|
25
|
+
if not color:
|
|
26
|
+
return text
|
|
27
|
+
return "".join(styles) + text + RESET
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def render_terminal(results: List[CheckResult], account: str, region: str,
|
|
31
|
+
color: bool = True) -> str:
|
|
32
|
+
lines = []
|
|
33
|
+
lines.append("")
|
|
34
|
+
lines.append(_c(" vigilai-scan", BOLD, color=color) + _c(
|
|
35
|
+
f" · account {account} · region {region}", DIM, color=color))
|
|
36
|
+
lines.append(_c(" 15 read-only checks mapped to SOC 2 — nothing is modified or sent anywhere.",
|
|
37
|
+
DIM, color=color))
|
|
38
|
+
lines.append("")
|
|
39
|
+
|
|
40
|
+
ordered = sorted(results, key=lambda r: (
|
|
41
|
+
0 if r.status == FAIL else 1 if r.status == WARN else 2 if r.status == ERROR else 3,
|
|
42
|
+
SEVERITY_ORDER.get(r.severity, 9), r.check_id))
|
|
43
|
+
|
|
44
|
+
for r in ordered:
|
|
45
|
+
style, label = STATUS_STYLE[r.status]
|
|
46
|
+
head = f" {_c(label, style, BOLD, color=color)} {r.check_id:<7} {r.title}"
|
|
47
|
+
head += _c(f" [{r.severity} · SOC2 {r.soc2}]", DIM, color=color)
|
|
48
|
+
lines.append(head)
|
|
49
|
+
if r.detail and r.status != PASS:
|
|
50
|
+
lines.append(_c(f" {r.detail}", color=color))
|
|
51
|
+
if r.fix and r.status in (FAIL, WARN):
|
|
52
|
+
lines.append(_c(f" fix → {r.fix}", DIM, color=color))
|
|
53
|
+
|
|
54
|
+
fails = sum(1 for r in results if r.status == FAIL)
|
|
55
|
+
warns = sum(1 for r in results if r.status == WARN)
|
|
56
|
+
skips = sum(1 for r in results if r.status == ERROR)
|
|
57
|
+
passes = sum(1 for r in results if r.status == PASS)
|
|
58
|
+
|
|
59
|
+
lines.append("")
|
|
60
|
+
summary = f" {passes} passed · {fails} failed · {warns} warnings"
|
|
61
|
+
if skips:
|
|
62
|
+
summary += f" · {skips} skipped (missing permissions)"
|
|
63
|
+
lines.append(_c(summary, BOLD, color=color))
|
|
64
|
+
|
|
65
|
+
if fails:
|
|
66
|
+
lines.append("")
|
|
67
|
+
lines.append(_c(" Every failing check has a ready-to-apply fix in the compliance kit:",
|
|
68
|
+
color=color))
|
|
69
|
+
lines.append(_c(" https://github.com/Joshugoi/VigilAI#the-kit",
|
|
70
|
+
BOLD, color=color))
|
|
71
|
+
lines.append("")
|
|
72
|
+
return "\n".join(lines)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def render_json(results: List[CheckResult], account: str, region: str) -> str:
|
|
76
|
+
return json.dumps({
|
|
77
|
+
"account": account,
|
|
78
|
+
"region": region,
|
|
79
|
+
"summary": {
|
|
80
|
+
"pass": sum(1 for r in results if r.status == PASS),
|
|
81
|
+
"fail": sum(1 for r in results if r.status == FAIL),
|
|
82
|
+
"warn": sum(1 for r in results if r.status == WARN),
|
|
83
|
+
"skipped": sum(1 for r in results if r.status == ERROR),
|
|
84
|
+
},
|
|
85
|
+
"checks": [r.to_dict() for r in results],
|
|
86
|
+
}, indent=2)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def supports_color(stream=sys.stdout) -> bool:
|
|
90
|
+
return hasattr(stream, "isatty") and stream.isatty()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vigilai-scan
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Read-only AWS compliance baseline scanner — 15 checks mapped to SOC 2 criteria, with the Terraform fix for every failure
|
|
5
|
+
Author: VigilAI
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: aws,compliance,soc2,security,scanner
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: boto3>=1.28
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# vigilai-scan
|
|
17
|
+
|
|
18
|
+
**Find out in 60 seconds why your AWS account would fail an enterprise security review.**
|
|
19
|
+
|
|
20
|
+
15 read-only checks against the controls that SOC 2 auditors, enterprise procurement teams, and security questionnaires actually ask about — each mapped to its SOC 2 criterion, each with a concrete fix.
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
FAIL LOG-01 A multi-region CloudTrail trail is logging [CRITICAL · SOC2 CC7.2]
|
|
24
|
+
FAIL NET-02 No security group opens SSH/RDP to the world [CRITICAL · SOC2 CC6.6]
|
|
25
|
+
SG(s) with 0.0.0.0/0 on admin ports: sg-0892…, sg-0b37…
|
|
26
|
+
FAIL S3-01 Account-level S3 public access block is on [CRITICAL · SOC2 CC6.6]
|
|
27
|
+
...
|
|
28
|
+
7 passed · 7 failed · 1 warnings
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Run it
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install vigilai-scan # or: pipx run vigilai-scan
|
|
35
|
+
vigilai-scan # uses your default AWS profile
|
|
36
|
+
vigilai-scan --profile prod --region us-west-2
|
|
37
|
+
vigilai-scan --json > report.json # CI-friendly; exits 1 on any FAIL
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**Read-only, local, zero telemetry.** Every AWS call is a `Describe`/`List`/`Get`. Nothing is modified, nothing leaves your machine. The safest way to run it is with the AWS-managed `SecurityAudit` policy. Audit the ~700 lines of source yourself — that's the point of it being open.
|
|
41
|
+
|
|
42
|
+
## What it checks
|
|
43
|
+
|
|
44
|
+
| ID | Check | SOC 2 |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| IAM-01 | Root account MFA | CC6.1 |
|
|
47
|
+
| IAM-02 | No IAM users with console passwords (SSO roles instead) | CC6.2 |
|
|
48
|
+
| IAM-03 | MFA on every human identity | CC6.1 |
|
|
49
|
+
| IAM-04 | No access keys older than 90 days | CC6.2 |
|
|
50
|
+
| IAM-05 | No `AdministratorAccess` attached directly to users | CC6.3 |
|
|
51
|
+
| S3-01 | Account-level public access block | CC6.6 |
|
|
52
|
+
| S3-02 | Default encryption on every bucket | CC6.1 |
|
|
53
|
+
| S3-03 | Per-bucket public access blocks | CC6.6 |
|
|
54
|
+
| NET-01 | Default security groups deny all traffic | CC6.6 |
|
|
55
|
+
| NET-02 | No SSH/RDP open to 0.0.0.0/0 | CC6.6 |
|
|
56
|
+
| NET-03 | VPC flow logs enabled | CC7.3 |
|
|
57
|
+
| NET-04 | Peering routes scoped tighter than /24 | CC6.6 |
|
|
58
|
+
| KMS-01 | Rotation on customer-managed keys | CC6.1 |
|
|
59
|
+
| LOG-01 | Active multi-region CloudTrail | CC7.2 |
|
|
60
|
+
| LOG-02 | EBS encryption-by-default | CC6.1 |
|
|
61
|
+
|
|
62
|
+
## The full baseline checklist
|
|
63
|
+
|
|
64
|
+
The scanner covers what's machine-checkable. The complete ~30-control checklist — including the process controls (access reviews, IR severity levels, vuln SLAs, change management) — is in [CHECKLIST.md](CHECKLIST.md). It's the engineering half of compliance, free under CC BY 4.0.
|
|
65
|
+
|
|
66
|
+
## The kit
|
|
67
|
+
|
|
68
|
+
Every failing check above has a ready-to-apply fix — a Terraform module or hardened template, plus the five corporate policies (Access Control, Change Management, Data Classification, Incident Response, Vulnerability Management) that enterprise reviewers ask for, pre-mapped to the code that enforces them.
|
|
69
|
+
|
|
70
|
+
> **⚡ Compliance Kit — $349 one-time.** Versioned Terraform modules + policies + control-to-code mapping, delivered as a private git repo. [Get it here.](https://YOUR-CHECKOUT-LINK)
|
|
71
|
+
>
|
|
72
|
+
> **🚀 Compliance Sprint — done with you in 14 days.** We run the scan, deploy the fixes into your account, customize the policies, and hand you a filled-out security questionnaire answer bank. [Book a free scan review.](https://calendly.com/banhjosh/30min)
|
|
73
|
+
|
|
74
|
+
## FAQ
|
|
75
|
+
|
|
76
|
+
**Why not just use Prowler / ScoutSuite?** They're excellent and far more exhaustive — hundreds of findings across every service. This is deliberately the opposite: the 15 checks an enterprise security reviewer will actually probe first, with a fix attached to each. Run this to get deal-ready this week; run Prowler when you have a security team.
|
|
77
|
+
|
|
78
|
+
**Will this get me SOC 2 certified?** No tool does. This gets your technical controls to the baseline auditors expect, which is most of the engineering work and the part that blocks deals.
|
|
79
|
+
|
|
80
|
+
**License:** MIT (scanner), CC BY 4.0 (checklist).
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
tests/test_checks.py
|
|
5
|
+
vigilai_scan/__init__.py
|
|
6
|
+
vigilai_scan/cli.py
|
|
7
|
+
vigilai_scan/registry.py
|
|
8
|
+
vigilai_scan/report.py
|
|
9
|
+
vigilai_scan.egg-info/PKG-INFO
|
|
10
|
+
vigilai_scan.egg-info/SOURCES.txt
|
|
11
|
+
vigilai_scan.egg-info/dependency_links.txt
|
|
12
|
+
vigilai_scan.egg-info/entry_points.txt
|
|
13
|
+
vigilai_scan.egg-info/requires.txt
|
|
14
|
+
vigilai_scan.egg-info/top_level.txt
|
|
15
|
+
vigilai_scan/checks/__init__.py
|
|
16
|
+
vigilai_scan/checks/iam.py
|
|
17
|
+
vigilai_scan/checks/kms.py
|
|
18
|
+
vigilai_scan/checks/logging_monitoring.py
|
|
19
|
+
vigilai_scan/checks/network.py
|
|
20
|
+
vigilai_scan/checks/s3.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
vigilai_scan
|