Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 36 additions & 8 deletions aws/aws/contracts_aws.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ def build_contract():
)

output_iam_privesc_paths = ContractOutputElement(
type=ContractOutputType.Text,
type=ContractOutputType.Vulnerability,
field="privesc_paths",
isMultiple=True,
isFindingCompatible=True,
Expand Down Expand Up @@ -255,6 +255,27 @@ def build_contract():
labels=["aws", "ec2", "security_group"],
)

# Public IPv4 addresses parsed out of EC2 / VPC enumeration, exposed as
# finding-compatible IPv4 primitives so they can chain into follow-up
# network injects. Shared across the EC2 instance and VPC contracts.
output_public_ips = ContractOutputElement(
type=ContractOutputType.IPv4,
field="public_ips",
isMultiple=True,
isFindingCompatible=True,
labels=["aws", "ipv4", "public"],
)

# Open security-group ports parsed out of EC2 enumeration, exposed as
# finding-compatible Port primitives.
output_open_ports = ContractOutputElement(
type=ContractOutputType.Port,
field="open_ports",
isMultiple=True,
isFindingCompatible=True,
labels=["aws", "ec2", "port"],
)

# Lambda Outputs
output_lambda_functions = ContractOutputElement(
type=ContractOutputType.Text,
Expand All @@ -273,18 +294,20 @@ def build_contract():
labels=["aws", "rds", "database"],
)

# Secrets Manager Outputs
# Secrets Manager Outputs -> Credentials (discovered credential-store
# entries; the enumeration surfaces the identifier, not the plaintext).
output_secrets = ContractOutputElement(
type=ContractOutputType.Text,
type=ContractOutputType.Credentials,
field="secrets",
isMultiple=True,
isFindingCompatible=True,
labels=["aws", "secretsmanager", "secret"],
)

# SSM Outputs
# SSM Outputs -> Credentials (SSM parameters frequently hold credential
# material; surfaced as credential-store references).
output_ssm_parameters = ContractOutputElement(
type=ContractOutputType.Text,
type=ContractOutputType.Credentials,
field="parameters",
isMultiple=True,
isFindingCompatible=True,
Expand Down Expand Up @@ -531,15 +554,20 @@ def make_contract(
EC2_ENUM_INSTANCES_CONTRACT,
"AWS - EC2 Enumerate Instances",
"AWS - Énumération des instances EC2",
[output_ec2_instances, output_ec2_security_groups],
[
output_ec2_instances,
output_ec2_security_groups,
output_public_ips,
output_open_ports,
],
attack_patterns=["T1580"],
)

ec2_enum_security_groups_contract = make_contract(
EC2_ENUM_SECURITY_GROUPS_CONTRACT,
"AWS - EC2 Enumerate Security Groups",
"AWS - Énumération des groupes de sécurité EC2",
[output_ec2_security_groups],
[output_ec2_security_groups, output_open_ports],
attack_patterns=["T1580"],
)

Expand Down Expand Up @@ -607,7 +635,7 @@ def make_contract(
VPC_ENUM_CONTRACT,
"AWS - VPC Enumerate Networks",
"AWS - Énumération des VPC",
[output_vpc_networks],
[output_vpc_networks, output_public_ips],
attack_patterns=["T1580"],
)

Expand Down
194 changes: 185 additions & 9 deletions aws/aws/helpers/pacu_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
This module handles the execution of Pacu commands and parsing of results.
"""

import ipaddress
import json
import os
import platform
import re
import subprocess
from typing import Dict, List, Optional, Tuple

Expand Down Expand Up @@ -755,17 +757,67 @@ def parse_results(self, results: Dict) -> Dict:
elif "ec2__enum" in module_name:
stdout = data.get("stdout", "")
instances, security_groups = self._parse_ec2_data(stdout)
public_ips = self._extract_public_ipv4s(stdout)
open_ports = self._extract_open_ports(stdout)
Comment thread
SamuelHassine marked this conversation as resolved.

message = f"Found {len(instances)} EC2 instances, {len(security_groups)} security groups"
message = (
f"Found {len(instances)} EC2 instances, "
f"{len(security_groups)} security groups, "
f"{len(public_ips)} public IPs, {len(open_ports)} open ports"
)
if self.logger:
self.logger.info(message)

outputs: Dict = {
"instances": instances,
"security_groups": security_groups,
}
if public_ips:
outputs["public_ips"] = public_ips
if open_ports:
outputs["open_ports"] = open_ports

return {
"success": True,
"message": message,
"outputs": outputs,
}

# Parse Secrets Manager enumeration -> Credentials findings
elif "secrets" in module_name:
stdout = data.get("stdout", "") or data.get("output", "")
secrets = self._parse_secrets_credentials(stdout)

message = f"Found {len(secrets)} secrets"
if self.logger:
self.logger.info(message)

return {
"success": True,
"message": message,
"outputs": {
"instances": instances,
"security_groups": security_groups,
"secrets": secrets,
},
}

# Parse SSM parameters enumeration -> Credentials findings
elif (
"systemsmanager" in module_name
or "ssm" in module_name
or "parameter" in module_name
):
stdout = data.get("stdout", "") or data.get("output", "")
parameters = self._parse_ssm_credentials(stdout)

message = f"Found {len(parameters)} SSM parameters"
if self.logger:
self.logger.info(message)

return {
"success": True,
"message": message,
"outputs": {
"parameters": parameters,
},
}

Expand Down Expand Up @@ -865,18 +917,63 @@ def _parse_iam_roles(self, stdout: str) -> List[str]:
roles.append(role_name)
return roles

def _parse_privesc_paths(self, stdout: str) -> List[str]:
"""Parse privilege escalation paths from Pacu output"""
privesc_paths = []
# Negative / failure phrases that also contain a privesc keyword but report
# the ABSENCE of a finding (e.g. "No potential privilege escalation methods
# worked."). Lines matching any of these must never be emitted as a
# Vulnerability finding.
_PRIVESC_NEGATIVE_MARKERS = (
"no potential",
"no privilege",
"no privesc",
"no escalation",
"no exploit",
"no methods",
"no method",
"no paths",
"no path",
"not vulnerable",
"none found",
"not found",
"did not",
"does not",
"could not",
"unable to",
)

def _parse_privesc_paths(self, stdout: str) -> List[Dict]:
"""Parse privilege escalation paths from Pacu output into Vulnerability
findings.

Each detected path is shaped as the platform Vulnerability output
processor expects: ``name`` and ``status`` are required, ``details``
carries the raw line. Negative / failure summaries (e.g. "No potential
privilege escalation methods worked.") also contain a privesc keyword,
so they are explicitly rejected to avoid emitting a false VULNERABLE
finding.
"""
privesc_paths: List[Dict] = []
seen = set()
for line in stdout.split("\n"):
line = line.strip()
lowered = line.lower()
# Look for privilege escalation indicators
if any(
keyword in line.lower()
keyword in lowered
for keyword in ["escalation", "privesc", "vulnerable", "exploit"]
):
if line not in privesc_paths:
privesc_paths.append(line)
if not line or line in seen:
continue
# Skip lines that report the absence of a finding.
if any(neg in lowered for neg in self._PRIVESC_NEGATIVE_MARKERS):
continue
seen.add(line)
privesc_paths.append(
{
Comment thread
SamuelHassine marked this conversation as resolved.
"name": line[:120],
"status": "VULNERABLE",
"details": line,
}
)
return privesc_paths

def _parse_ec2_data(self, stdout: str) -> Tuple[List[str], List[str]]:
Expand Down Expand Up @@ -939,6 +1036,7 @@ def _generic_parse(self, module_name: str, stdout: str) -> Dict:
)
elif "vpc" in module_name:
outputs["vpcs"] = self._extract_items(stdout, ["vpc-", "VPC:", "VpcId:"])
outputs["public_ips"] = self._extract_public_ipv4s(stdout)
elif "cloudtrail" in module_name:
outputs["events"] = self._extract_items(
stdout, ["Event:", "EventName:", "Trail:"]
Expand Down Expand Up @@ -998,6 +1096,84 @@ def _generic_parse(self, module_name: str, stdout: str) -> Dict:

return outputs

def _parse_secrets_credentials(self, stdout: str) -> List[Dict]:
"""Parse Secrets Manager identifiers into Credentials findings."""
identifiers = self._extract_items(stdout, ["SecretName:", "Secret:", "ARN:"])
return self._identifiers_to_credentials(identifiers)

def _parse_ssm_credentials(self, stdout: str) -> List[Dict]:
"""Parse SSM parameter identifiers into Credentials findings."""
identifiers = self._extract_items(stdout, ["Parameter:", "Name:", "SSM:"])
return self._identifiers_to_credentials(identifiers)

def _identifiers_to_credentials(self, identifiers: List[str]) -> List[Dict]:
"""Shape credential-store identifiers as Credentials findings.

The enumeration modules surface the identifier of a secret / parameter,
not the plaintext value, so the identifier is used as both the username
and the hash to satisfy the platform Credentials validator (username +
password OR hash). The finding acts as a lead for credential-reuse
injects.
"""
credentials: List[Dict] = []
seen = set()
for identifier in identifiers:
if not identifier or identifier in seen:
continue
seen.add(identifier)
credentials.append({"username": identifier, "hash": identifier})
return credentials

def _extract_public_ipv4s(self, stdout: str) -> List[str]:
"""Extract distinct, globally-routable IPv4 addresses from Pacu output."""
public_ips: List[str] = []
seen = set()
for candidate in re.findall(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", stdout):
if candidate in seen:
continue
try:
address = ipaddress.ip_address(candidate)
except ValueError:
continue
if address.version != 4 or not address.is_global:
continue
seen.add(candidate)
public_ips.append(candidate)
return public_ips

def _extract_open_ports(self, stdout: str) -> List[int]:
"""Extract distinct open security-group ports from Pacu output.

Security-group rules are expressed as ``FromPort`` / ``ToPort`` pairs. A
pair only identifies a single open port when ``FromPort == ToPort``; a
genuine range (e.g. ``FromPort: 80 ToPort: 82``) cannot be represented by
the contract's list-of-single-``Port`` output, so it is skipped rather
than misrepresented as just its two endpoints. Explicit "open port N"
lines are parsed separately.
"""
open_ports: List[int] = []
seen = set()

def _add(value: int) -> None:
if 0 < value <= 65535 and value not in seen:
seen.add(value)
open_ports.append(value)

# Single-port security-group rules (FromPort == ToPort). ``\D+`` between
# the two values never crosses another digit, so it cannot pair a
# FromPort with a ToPort from a different rule.
for from_port, to_port in re.findall(
r"FromPort\W{0,3}(\d{1,5})\D+ToPort\W{0,3}(\d{1,5})", stdout
):
if from_port == to_port:
_add(int(from_port))

# Explicit "open port N" mentions.
for match in re.findall(r"open port\W{0,3}(\d{1,5})", stdout, re.IGNORECASE):
_add(int(match))

return open_ports

def _extract_items(self, text: str, patterns: List[str]) -> List[str]:
"""Extract items matching any of the given patterns"""
items = []
Expand Down
Empty file added aws/test/__init__.py
Empty file.
Loading
Loading