Skip to content

Lab 07 β€” Security API Integration

Difficulty: Intermediate β†’ Advanced
Estimated Time: 120–180 minutes
Primary Language: Python
Security Domain: Security Automation / SOC / Cloud Security / Vulnerability Management
Environment: Local controlled lab
Automation Type: Defensive API Integration

Your task is to build a reusable Python security API client.

Instead of manually opening a security dashboard and exporting data, your script will interact with a controlled local training API.

You will learn how to:

AUTHENTICATE
SEND API REQUESTS
USE HTTP METHODS
PASS PARAMETERS
READ JSON
VALIDATE RESPONSES
HANDLE TIMEOUTS
HANDLE ERRORS
IMPLEMENT RETRIES
UNDERSTAND RATE LIMITS
NORMALIZE SECURITY DATA
GENERATE REPORTS

The final workflow will be:

SECURITY PLATFORM
↓
REST API
↓
PYTHON CLIENT
↓
AUTHENTICATE
↓
REQUEST
↓
VALIDATE RESPONSE
↓
PARSE JSON
↓
NORMALIZE
↓
ANALYZE
↓
REPORT

Modern security platforms are heavily API-driven.

Examples include:

SIEM
EDR / XDR
CLOUD SECURITY PLATFORMS
VULNERABILITY MANAGEMENT
THREAT INTELLIGENCE
IDENTITY SECURITY
TICKETING SYSTEMS
SOAR
ASSET MANAGEMENT
COMPLIANCE PLATFORMS

A security automation engineer may need to connect:

VULNERABILITY PLATFORM
↓
PYTHON
↓
TICKETING SYSTEM

or:

SIEM ALERT
↓
API
↓
THREAT INTELLIGENCE
↓
ENRICHED ALERT

or:

CLOUD SECURITY API
↓
FINDINGS
↓
NORMALIZATION
↓
SECURITY DASHBOARD

By completing this lab, you should be able to:

UNDERSTAND REST APIs
UNDERSTAND HTTP METHODS
USE API ENDPOINTS
USE QUERY PARAMETERS
USE HEADERS
USE BEARER TOKENS
PROTECT API SECRETS
SEND GET REQUESTS
SEND CONTROLLED POST REQUESTS
PARSE JSON
VALIDATE HTTP STATUS CODES
CONFIGURE TIMEOUTS
HANDLE NETWORK ERRORS
IMPLEMENT RETRIES
UNDERSTAND BACKOFF
UNDERSTAND RATE LIMITING
BUILD PAGINATION LOGIC
NORMALIZE API DATA
EXPORT SECURITY REPORTS
BUILD A REUSABLE API CLIENT
SECURITY AUTOMATION
↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ API CLIENT β”‚
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
↓
AUTHENTICATE
↓
HTTP REQUEST
↓
API ENDPOINT
↓
HTTP RESPONSE
↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
↓ ↓ ↓
STATUS HEADERS JSON
↓ ↓ ↓
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
↓
VALIDATE
↓
NORMALIZE
↓
ANALYZE
↓
SECURITY REPORT

This lab uses:

A LOCAL TRAINING API

and:

SYNTHETIC SECURITY DATA

Do not connect automation to a production security platform until you understand:

API PERMISSIONS
DATA SENSITIVITY
RATE LIMITS
CHANGE IMPACT
ERROR HANDLING
AUDIT REQUIREMENTS

For this lab, API actions remain:

READ-ONLY

except for an optional local training POST request that creates only synthetic lab records.

Create:

security-api-integration/
|
+-- api/
|
+-- data/
|
+-- reports/
|
+-- src/
|
+-- tests/
|
+-- README.md

Linux/macOS:

Terminal window
mkdir -p security-api-integration/{api,data,reports,src,tests}
cd security-api-integration

PowerShell:

Terminal window
mkdir security-api-integration
cd security-api-integration
mkdir api
mkdir data
mkdir reports
mkdir src
mkdir tests

Run:

Terminal window
python --version

Recommended:

Python 3.10+

Before coding, understand:

CLIENT

The application making the request.

SERVER

The application responding.

ENDPOINT

A specific API path.

Example:

/api/findings
REQUEST

What the client sends.

RESPONSE

What the server returns.

CLIENT
↓
HTTP REQUEST
↓
ENDPOINT
↓
SERVER
↓
HTTP RESPONSE
↓
CLIENT

Common methods:

Method Typical Purpose
GET Retrieve data
POST Create data
PUT Replace data
PATCH Modify part of a resource
DELETE Remove data

For this lab, focus mainly on:

GET

and one controlled local:

POST

Important examples:

200
Success
201
Created
400
Bad Request
401
Unauthorized
403
Forbidden
404
Not Found
429
Too Many Requests
500
Server Error
503
Service Unavailable

Do not write:

REQUEST COMPLETED
=
SUCCESS

The actual workflow is:

REQUEST SENT
↓
RESPONSE RECEIVED
↓
STATUS CODE?
↓
VALID?
↓
PARSE DATA

To keep the lab controlled, create a small local API.

Create:

api/training_api.py

For the server, use:

from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, parse_qs
import json

This avoids requiring an external framework for the lab.

Add:

FINDINGS = [
{
"id": "F-1001",
"asset": "WEB01",
"type": "vulnerability",
"severity": "critical",
"status": "open",
"score": 9.8
},
{
"id": "F-1002",
"asset": "DB01",
"type": "vulnerability",
"severity": "high",
"status": "open",
"score": 8.2
},
{
"id": "F-1003",
"asset": "APP01",
"type": "configuration",
"severity": "medium",
"status": "open",
"score": 5.4
},
{
"id": "F-1004",
"asset": "VPN01",
"type": "configuration",
"severity": "high",
"status": "closed",
"score": 7.6
},
{
"id": "F-1005",
"asset": "JUMP01",
"type": "identity",
"severity": "critical",
"status": "open",
"score": 9.1
}
]

Add:

API_TOKEN = "training-token-123"

This is acceptable only because:

THE SERVER IS LOCAL
THE TOKEN IS SYNTHETIC
THE VALUE HAS NO REAL PRIVILEGE

Do not copy this pattern for production credentials.

Add:

class TrainingAPIHandler(
BaseHTTPRequestHandler
):
def send_json(
self,
data,
status=200
):
body = json.dumps(
data
).encode(
"utf-8"
)
self.send_response(
status
)
self.send_header(
"Content-Type",
"application/json"
)
self.send_header(
"Content-Length",
str(
len(body)
)
)
self.end_headers()
self.wfile.write(
body
)

Add:

def is_authorized(
self
):
authorization = (
self.headers.get(
"Authorization",
""
)
)
expected = (
f"Bearer {API_TOKEN}"
)
return (
authorization
==
expected
)

A common API pattern is:

Authorization:
Bearer TOKEN

Conceptually:

CLIENT
↓
TOKEN
↓
API
↓
IDENTITY / PERMISSION CHECK

Add:

def do_GET(
self
):
if not self.is_authorized():
self.send_json(
{
"error":
"unauthorized"
},
401
)
return
parsed = urlparse(
self.path
)
if parsed.path == "/api/findings":
self.handle_findings(
parsed
)
return
if parsed.path == "/api/health":
self.send_json(
{
"status":
"ok"
}
)
return
self.send_json(
{
"error":
"not_found"
},
404
)

Create:

def handle_findings(
self,
parsed
):
query = parse_qs(
parsed.query
)
severity = query.get(
"severity",
[None]
)[0]
status = query.get(
"status",
[None]
)[0]
results = FINDINGS
if severity:
results = [
item
for item in results
if item[
"severity"
] == severity.lower()
]
if status:
results = [
item
for item in results
if item[
"status"
] == status.lower()
]
self.send_json(
{
"count":
len(results),
"results":
results
}
)

Add:

def main():
server = HTTPServer(
(
"127.0.0.1",
8080
),
TrainingAPIHandler
)
print(
"Training API listening on "
"http://127.0.0.1:8080"
)
server.serve_forever()

Then:

if __name__ == "__main__":
main()

Run in Terminal 1:

Terminal window
python api/training_api.py

Expected:

Training API listening on http://127.0.0.1:8080

For now use Python rather than relying on additional tools.

Create:

src/test_connection.py

Add:

from urllib.request import Request, urlopen
request = Request(
"http://127.0.0.1:8080/api/health"
)
request.add_header(
"Authorization",
"Bearer training-token-123"
)
with urlopen(
request,
timeout=5
) as response:
print(
response.read().decode(
"utf-8"
)
)

Run in Terminal 2:

Terminal window
python src/test_connection.py

Expected:

{"status": "ok"}

Change the token temporarily to:

wrong-token

Run again.

Expected:

HTTP 401

Restore the correct synthetic token afterward.

Your script must distinguish between:

NETWORK FAILURE

and:

AUTHENTICATION FAILURE

because the troubleshooting action is different.

Create:

src/security_api_client.py

Start with:

from pathlib import Path
from urllib.request import Request, urlopen
from urllib.parse import urlencode
from urllib.error import HTTPError, URLError
import csv
import json
import logging
import os
import time

Add:

BASE_DIR = Path(__file__).resolve().parent.parent
REPORT_DIR = BASE_DIR / "reports"
REPORT_DIR.mkdir(
parents=True,
exist_ok=True
)

Add:

logging.basicConfig(
level=logging.INFO,
format=(
"%(asctime)s "
"%(levelname)s "
"%(message)s"
)
)

Add:

BASE_URL = (
"http://127.0.0.1:8080"
)
DEFAULT_TIMEOUT = 5
MAX_RETRIES = 3

Do not hard-code the client token.

Use:

API_TOKEN = os.getenv(
"SECURITY_API_TOKEN"
)

Linux/macOS:

Terminal window
export SECURITY_API_TOKEN="training-token-123"

PowerShell:

Terminal window
$env:SECURITY_API_TOKEN = "training-token-123"

This environment variable lasts for the relevant shell/session.

They are better than:

API_TOKEN = "secret"

because hard-coded secrets can leak into:

GIT
BACKUPS
LOGS
SCREENSHOTS
CODE REVIEWS

29 β€” Environment Variables Are Not Perfect Secret Management

Section titled β€œ29 β€” Environment Variables Are Not Perfect Secret Management”

Production environments should consider:

SECRET MANAGERS
WORKLOAD IDENTITY
MANAGED IDENTITIES
SHORT-LIVED TOKENS

The lab uses an environment variable to teach the fundamental pattern.

Create:

def validate_configuration():
if not API_TOKEN:
raise RuntimeError(
"SECURITY_API_TOKEN "
"environment variable is not set."
)

Create:

def build_headers():
return {
"Authorization":
f"Bearer {API_TOKEN}",
"Accept":
"application/json",
"User-Agent":
"GoHackersCloud-Security-Lab/1.0"
}

A useful API client should identify itself.

This supports:

LOGGING
TROUBLESHOOTING
AUDITING
API ANALYTICS

Create:

def build_url(
endpoint,
params=None
):
url = (
BASE_URL.rstrip("/")
+
"/"
+
endpoint.lstrip("/")
)
if params:
query = urlencode(
params
)
url = (
f"{url}?{query}"
)
return url

Avoid manually constructing:

?severity=critical&status=open

because encoding rules become error-prone.

Use:

STRUCTURED PARAMETERS

Create:

def send_get_request(
endpoint,
params=None,
timeout=DEFAULT_TIMEOUT
):
url = build_url(
endpoint,
params
)
request = Request(
url,
method="GET"
)
for name, value in (
build_headers().items()
):
request.add_header(
name,
value
)
with urlopen(
request,
timeout=timeout
) as response:
return {
"status":
response.status,
"headers":
dict(
response.headers
),
"body":
response.read()
.decode(
"utf-8"
)
}

Test:

response = send_get_request(
"/api/findings"
)
print(response)

Create:

def parse_json_response(
body
):
try:
return json.loads(
body
)
except json.JSONDecodeError as error:
raise ValueError(
"API returned invalid JSON"
) from error

Do not assume:

Content-Type: application/json

means the body always contains valid JSON.

Systems can return:

HTML ERROR PAGE
EMPTY BODY
TRUNCATED RESPONSE
INVALID JSON

Create:

def get_findings(
severity=None,
status=None
):
params = {}
if severity:
params[
"severity"
] = severity
if status:
params[
"status"
] = status
response = send_get_request(
"/api/findings",
params=params
)
if response[
"status"
] != 200:
raise RuntimeError(
"Unexpected response status"
)
data = parse_json_response(
response[
"body"
]
)
return data

Run:

data = get_findings(
severity="critical"
)
print(
json.dumps(
data,
indent=2
)
)

Expected:

WEB01
JUMP01

Run:

data = get_findings(
status="open"
)

Run:

data = get_findings(
severity="critical",
status="open"
)

Your client generates:

/api/findings?severity=critical&status=open

This is different from sending filtering instructions in:

HTTP HEADERS

or:

REQUEST BODY

Currently:

401
404
429
500

can raise:

HTTPError

Create:

def handle_http_error(
error
):
if error.code == 401:
return (
"Authentication failed"
)
if error.code == 403:
return (
"Request forbidden"
)
if error.code == 404:
return (
"API endpoint not found"
)
if error.code == 429:
return (
"API rate limit reached"
)
if 500 <= error.code <= 599:
return (
"API server error"
)
return (
f"HTTP error {error.code}"
)

Examples include:

SERVER NOT RUNNING
DNS FAILURE
CONNECTION REFUSED
ROUTING ISSUE

These often appear as:

URLError

Create:

def api_get(
endpoint,
params=None
):
try:
response = send_get_request(
endpoint,
params=params
)
return {
"success":
True,
"status":
response[
"status"
],
"data":
parse_json_response(
response[
"body"
]
),
"error":
None
}
except HTTPError as error:
message = (
handle_http_error(
error
)
)
logging.error(
message
)
return {
"success":
False,
"status":
error.code,
"data":
None,
"error":
message
}
except URLError as error:
message = (
f"Network error: "
f"{error.reason}"
)
logging.error(
message
)
return {
"success":
False,
"status":
None,
"data":
None,
"error":
message
}
except ValueError as error:
logging.error(
str(error)
)
return {
"success":
False,
"status":
None,
"data":
None,
"error":
str(error)
}

Call:

result = api_get(
"/api/not-real"
)
print(result)

Expected:

404

with a clean application error rather than a full uncontrolled crash.

Stop:

training_api.py

Then run the client.

Expected:

NETWORK ERROR

Restart the server afterward.

Never create API automation that waits forever.

Without a timeout:

REQUEST
↓
SERVER NEVER RESPONDS
↓
AUTOMATION HANGS

With a timeout:

REQUEST
↓
WAIT LIMITED TIME
↓
FAIL SAFELY

You already configured:

DEFAULT_TIMEOUT = 5

Every request should have an explicit timeout.

Some errors are:

TEMPORARY

such as:

503 SERVICE UNAVAILABLE
CONNECTION RESET
TRANSIENT NETWORK ISSUE

Others are not good retry candidates:

401 UNAUTHORIZED
403 FORBIDDEN
400 BAD REQUEST
REQUEST
↓
TEMPORARY FAILURE?
↓ YES
WAIT
↓
RETRY
↓
SUCCESS?

Avoid:

FAIL
↓
RETRY IMMEDIATELY
↓
FAIL
↓
RETRY IMMEDIATELY

Prefer:

FAIL
↓
WAIT 1 SECOND
↓
FAIL
↓
WAIT 2 SECONDS
↓
FAIL
↓
WAIT 4 SECONDS

Create:

def api_get_with_retry(
endpoint,
params=None,
max_retries=MAX_RETRIES
):
for attempt in range(
max_retries
):
result = api_get(
endpoint,
params=params
)
if result[
"success"
]:
return result
status = result[
"status"
]
retryable = (
status is None
or
status == 429
or
(
status
and
500 <= status <= 599
)
)
if not retryable:
return result
if attempt < (
max_retries - 1
):
delay = (
2 ** attempt
)
logging.warning(
f"Retrying in "
f"{delay} seconds"
)
time.sleep(
delay
)
return result

Without a limit:

SERVICE FAILURE
↓
AUTOMATION RETRIES FOREVER

This can create:

RESOURCE EXHAUSTION
API PRESSURE
FAILED WORKFLOWS
NOISY LOGGING

APIs may restrict usage to values such as:

100 REQUESTS / MINUTE

or:

1,000 REQUESTS / HOUR

A common rate-limit response is:

429 Too Many Requests

The API may also provide:

Retry-After

or custom headers.

CLIENT
↓
REQUEST REQUEST REQUEST
↓
API LIMIT
↓
429
↓
WAIT
↓
CONTINUE

Do not design:

429
↓
OPEN MORE THREADS
↓
SEND MORE REQUESTS

Respect provider limits.

Large APIs usually do not return:

100,000 FINDINGS

in one response.

Instead:

PAGE 1
PAGE 2
PAGE 3
REQUEST PAGE 1
↓
PROCESS
↓
MORE?
↓
REQUEST PAGE 2
↓
PROCESS

Modify:

handle_findings()

to accept:

page
page_size

Add:

page = int(
query.get(
"page",
["1"]
)[0]
)
page_size = int(
query.get(
"page_size",
["2"]
)[0]
)

Add:

start = (
page - 1
) * page_size
end = (
start
+
page_size
)
paged_results = (
results[
start:end
]
)

Change response to:

self.send_json(
{
"page":
page,
"page_size":
page_size,
"total":
len(results),
"results":
paged_results
}
)

Restart the server.

Create:

def get_all_findings(
page_size=2
):
all_findings = []
page = 1
while True:
result = (
api_get_with_retry(
"/api/findings",
params={
"page":
page,
"page_size":
page_size
}
)
)
if not result[
"success"
]:
raise RuntimeError(
result[
"error"
]
)
data = result[
"data"
]
records = data[
"results"
]
all_findings.extend(
records
)
if len(
all_findings
) >= data[
"total"
]:
break
page += 1
return all_findings

Test:

findings = get_all_findings()
print(
len(findings)
)

Expected:

5

Always protect against:

ENDLESS PAGINATION

caused by buggy APIs.

A mature client can enforce:

MAXIMUM PAGE COUNT

Different vendors may return different fields.

Vendor A:

{
"machine": "WEB01",
"risk": "CRITICAL"
}

Vendor B:

{
"host": "WEB01",
"severity": "critical"
}

Your internal model should be consistent.

Use:

finding_id
asset
finding_type
severity
status
score
source

Create:

def normalize_finding(
record
):
required = {
"id",
"asset",
"type",
"severity",
"status",
"score"
}
missing = (
required
-
set(
record.keys()
)
)
if missing:
raise ValueError(
"Missing fields: "
+
", ".join(
sorted(
missing
)
)
)
return {
"finding_id":
str(
record[
"id"
]
),
"asset":
str(
record[
"asset"
]
).strip().upper(),
"finding_type":
str(
record[
"type"
]
).strip().lower(),
"severity":
str(
record[
"severity"
]
).strip().lower(),
"status":
str(
record[
"status"
]
).strip().lower(),
"score":
float(
record[
"score"
]
),
"source":
"training-api"
}

Improve:

score = float(
record["score"]
)
if not (
0.0 <= score <= 10.0
):
raise ValueError(
"Score outside expected range"
)

Without normalization:

EACH API
=
DIFFERENT DATA MODEL

With normalization:

MULTIPLE APIS
↓
COMMON SCHEMA
↓
CENTRAL ANALYTICS

Create:

def normalize_findings(
records
):
valid = []
invalid = []
for record in records:
try:
valid.append(
normalize_finding(
record
)
)
except (
ValueError,
TypeError
) as error:
invalid.append({
"record":
record,
"reason":
str(error)
})
return valid, invalid

Import:

from collections import Counter

Create:

def count_by_severity(
findings
):
return Counter(
item[
"severity"
]
for item
in findings
)

Create:

def get_open_findings(
findings
):
return [
item
for item
in findings
if item[
"status"
] == "open"
]

Create:

def get_critical_open(
findings
):
return [
item
for item
in findings
if (
item[
"status"
] == "open"
and
item[
"severity"
] == "critical"
)
]

Create:

def export_findings_csv(
findings
):
output = (
REPORT_DIR /
"api-findings.csv"
)
fields = [
"finding_id",
"asset",
"finding_type",
"severity",
"status",
"score",
"source"
]
with output.open(
"w",
encoding="utf-8",
newline=""
) as file:
writer = csv.DictWriter(
file,
fieldnames=fields
)
writer.writeheader()
writer.writerows(
findings
)

Create:

def export_invalid_records(
records
):
output = (
REPORT_DIR /
"invalid-api-records.json"
)
with output.open(
"w",
encoding="utf-8"
) as file:
json.dump(
records,
file,
indent=2
)

A useful defensive practice is to preserve the raw source data.

Create:

def export_raw_snapshot(
records
):
output = (
REPORT_DIR /
"raw-api-response.json"
)
with output.open(
"w",
encoding="utf-8"
) as file:
json.dump(
records,
file,
indent=2
)

If normalization behaves incorrectly, you can compare:

RAW API DATA

with:

NORMALIZED OUTPUT

Create:

def generate_summary(
findings
):
return {
"total":
len(findings),
"open":
len(
get_open_findings(
findings
)
),
"critical_open":
len(
get_critical_open(
findings
)
),
"severity_counts":
dict(
count_by_severity(
findings
)
)
}

Create:

def export_summary(
summary
):
output = (
REPORT_DIR /
"api-summary.json"
)
with output.open(
"w",
encoding="utf-8"
) as file:
json.dump(
summary,
file,
indent=2
)

Create:

def generate_markdown_report(
findings,
summary
):
lines = []
lines.append(
"# Security API Integration Report"
)
lines.append("")
lines.append(
"## Executive Summary"
)
lines.append("")
lines.append(
f"- Total findings: "
f"{summary['total']}"
)
lines.append(
f"- Open findings: "
f"{summary['open']}"
)
lines.append(
f"- Critical open findings: "
f"{summary['critical_open']}"
)
lines.append("")
lines.append(
"## Critical Open Findings"
)
lines.append("")
critical = (
get_critical_open(
findings
)
)
if critical:
for item in critical:
lines.append(
f"- {item['finding_id']} | "
f"{item['asset']} | "
f"{item['finding_type']} | "
f"Score {item['score']}"
)
else:
lines.append(
"- No critical open findings."
)
lines.append("")
lines.append(
"## Integration Notes"
)
lines.append("")
lines.append(
"The client uses a controlled training API. "
"Production integrations should use approved "
"credentials, least-privilege permissions, "
"timeouts, retry controls, rate-limit handling, "
"logging, and documented data-retention policies."
)
output = (
REPORT_DIR /
"security-api-report.md"
)
output.write_text(
"\n".join(
lines
),
encoding="utf-8"
)

Create:

def main():
validate_configuration()
logging.info(
"Starting security API collection"
)
raw_findings = (
get_all_findings()
)
export_raw_snapshot(
raw_findings
)
findings, invalid = (
normalize_findings(
raw_findings
)
)
summary = generate_summary(
findings
)
export_findings_csv(
findings
)
export_invalid_records(
invalid
)
export_summary(
summary
)
generate_markdown_report(
findings,
summary
)
logging.info(
"Security API collection complete"
)
print(
f"Reports saved to: "
f"{REPORT_DIR}"
)

Add:

if __name__ == "__main__":
main()

Terminal 1:

Terminal window
python api/training_api.py

Terminal 2:

Linux/macOS:

Terminal window
export SECURITY_API_TOKEN="training-token-123"
python src/security_api_client.py

PowerShell:

Terminal window
$env:SECURITY_API_TOKEN = "training-token-123"
python .\src\security_api_client.py

You should have:

reports/
|
+-- raw-api-response.json
|
+-- api-findings.csv
|
+-- invalid-api-records.json
|
+-- api-summary.json
|
+-- security-api-report.md

Expected structure:

finding_id,asset,finding_type,severity,status,score,source
F-1001,WEB01,vulnerability,critical,open,9.8,training-api
F-1002,DB01,vulnerability,high,open,8.2,training-api

The client should record:

START
REQUEST FAILURES
RETRIES
NORMALIZATION ERRORS
COMPLETION

Do not log:

BEARER TOKEN

Never write:

logging.info(
request.headers
)

when those headers contain:

AUTHORIZATION
API TOKEN
SESSION COOKIE

Now learn a second HTTP method using only your local API.

The goal:

CREATE A SYNTHETIC
SECURITY NOTE

not perform a production action.

At the top of:

training_api.py

add:

NOTES = []

Inside the handler:

def do_POST(
self
):
if not self.is_authorized():
self.send_json(
{
"error":
"unauthorized"
},
401
)
return
parsed = urlparse(
self.path
)
if parsed.path != "/api/notes":
self.send_json(
{
"error":
"not_found"
},
404
)
return
content_length = int(
self.headers.get(
"Content-Length",
"0"
)
)
body = self.rfile.read(
content_length
)
try:
data = json.loads(
body.decode(
"utf-8"
)
)
except json.JSONDecodeError:
self.send_json(
{
"error":
"invalid_json"
},
400
)
return
note = str(
data.get(
"note",
""
)
).strip()
if not note:
self.send_json(
{
"error":
"note_required"
},
400
)
return
record = {
"id":
len(NOTES) + 1,
"note":
note
}
NOTES.append(
record
)
self.send_json(
record,
201
)

Restart the server.

In:

security_api_client.py

create:

def send_post_request(
endpoint,
payload,
timeout=DEFAULT_TIMEOUT
):
url = build_url(
endpoint
)
body = json.dumps(
payload
).encode(
"utf-8"
)
request = Request(
url,
data=body,
method="POST"
)
headers = build_headers()
headers[
"Content-Type"
] = "application/json"
for name, value in (
headers.items()
):
request.add_header(
name,
value
)
with urlopen(
request,
timeout=timeout
) as response:
return {
"status":
response.status,
"body":
response.read()
.decode(
"utf-8"
)
}

Run:

result = send_post_request(
"/api/notes",
{
"note":
"Analyst reviewed F-1001 "
"in the training lab."
}
)
print(result)

Expected:

201

GET is generally used to:

READ DATA

POST may:

CREATE STATE

In production, writes require stronger controls.

Prefer:

DETECTION
↓
VALIDATE
↓
PREVIEW
↓
HUMAN APPROVAL
↓
API WRITE
↓
VERIFY
↓
AUDIT LOG

Do not build:

ALERT
↓
API
↓
DELETE ACCOUNT

or:

IOC MATCH
↓
API
↓
BLOCK EVERYTHING

without appropriate governance and controls.

Prefer separate permissions.

Example:

REPORTING TOKEN
↓
READ FINDINGS

instead of:

ADMIN TOKEN
↓
EVERYTHING
AUTOMATION PURPOSE
↓
REQUIRED ENDPOINTS
↓
REQUIRED METHODS
↓
MINIMUM PERMISSIONS
↓
TOKEN SCOPE

Production tokens should support:

ROTATION
EXPIRATION
REVOCATION

Long-lived permanent credentials increase risk.

Cloud environments may avoid static API secrets entirely.

Conceptually:

AUTOMATION WORKLOAD
↓
PLATFORM IDENTITY
↓
SHORT-LIVED CREDENTIAL
↓
API

Production APIs should generally use:

HTTPS

Your lab uses:

HTTP

only because the API runs locally on:

127.0.0.1

Do not normalize practices such as:

VERIFY TLS = FALSE

against production APIs.

Certificate verification protects against:

SERVER IMPERSONATION
MAN-IN-THE-MIDDLE RISK

Before integration, determine whether API data contains:

USER DATA
INTERNAL HOSTNAMES
IP ADDRESSES
VULNERABILITIES
SECURITY ALERTS
INCIDENT INFORMATION
CREDENTIAL METADATA

If the report requires:

finding_id
asset
severity

do not collect:

50 EXTRA FIELDS

without a reason.

A mature API may return:

X-Request-ID

or similar.

Store such identifiers in logs so failures can be traced.

You can also generate your own internal ID.

Import:

import uuid

Create:

request_id = str(
uuid.uuid4()
)

Then log:

logging.info(
"request_id=%s starting request",
request_id
)

Useful API automation metrics include:

REQUEST COUNT
SUCCESS RATE
ERROR RATE
401 COUNT
429 COUNT
5XX COUNT
AVERAGE RESPONSE TIME
RETRY COUNT
RECORDS PROCESSED

Import:

from time import perf_counter

Concept:

start = perf_counter()
result = api_get(
"/api/findings"
)
duration = (
perf_counter()
-
start
)
logging.info(
"Request duration: %.3f seconds",
duration
)

Repeated enrichment requests may benefit from:

CACHE

Architecture:

REQUEST DATA
↓
CACHE HIT?
β”Œβ”€β”€β”΄β”€β”€β”
YES NO
↓ ↓
USE API
CACHE ↓
SAVE

Cached API data may itself be sensitive.

Protect it with:

ACCESS CONTROL
RETENTION
ENCRYPTION WHERE REQUIRED

Security data can become stale.

Use:

CACHE TIMESTAMP
TTL
EXPIRATION

An important API concept:

SAME REQUEST
EXECUTED MULTIPLE TIMES

should not unexpectedly create repeated state where idempotency is required.

This is especially important for:

TICKET CREATION
FIREWALL CHANGES
ACCOUNT CHANGES
CASE CREATION

Suppose automation creates incidents.

Avoid:

SAME ALERT
↓
5 RETRIES
↓
5 INCIDENTS

Use:

EVENT ID
IDEMPOTENCY KEY
EXISTING CASE CHECK

Production APIs often use paths such as:

/api/v1/findings

or:

/api/v2/findings

Do not assume API structures never change.

A vendor may change:

risk

to:

severity

Your normalization layer helps isolate those changes.

Conceptually:

VENDOR A API
↓
ADAPTER A
↓
COMMON MODEL
VENDOR B API
↓
ADAPTER B
↓
COMMON MODEL

Your reporting layer should not care whether data came from:

SIEM A
SIEM B
CLOUD SECURITY TOOL
VULNERABILITY TOOL

It should receive:

NORMALIZED SECURITY DATA

Final mental architecture:

SOURCE API
↓
AUTHENTICATE
↓
REQUEST
↓
STATUS VALIDATION
↓
JSON VALIDATION
↓
SCHEMA VALIDATION
↓
NORMALIZATION
↓
DEDUPLICATION
↓
ANALYSIS
↓
REPORT

Remove:

SECURITY_API_TOKEN

Run the client.

Expected:

CONFIGURATION ERROR

before API requests are sent.

Set:

wrong-token

Expected:

401 Unauthorized

Stop the server.

Expected:

NETWORK ERROR
RETRIES
FINAL FAILURE

Request:

/api/unknown

Expected:

404

and:

NO RETRY

Temporarily modify the training API to return invalid text.

Confirm:

JSON VALIDATION FAILS

Then restore the valid server.

Remove:

severity

from one synthetic API finding.

Confirm that normalization sends the record to:

INVALID RECORDS

Set:

score = "invalid"

Confirm the client does not crash.

Set:

page_size = 1

Confirm:

ALL 5 RECORDS

are still collected.

Add a duplicate finding to the server.

Then extend your client to deduplicate using:

finding_id

Create:

def deduplicate_findings(
findings
):
unique = []
duplicates = []
seen = set()
for finding in findings:
key = finding[
"finding_id"
]
if key in seen:
duplicates.append(
finding
)
continue
seen.add(
key
)
unique.append(
finding
)
return unique, duplicates

Duplicate results can occur because of:

PAGINATION CHANGES
API BUGS
REPEATED IMPORT
EVENTUAL CONSISTENCY
RETRY LOGIC

Create:

tests/test_security_api_client.py
def test_build_url():
url = build_url(
"/api/findings",
{
"severity":
"critical"
}
)
assert (
"severity=critical"
in url
)
def test_normalize_finding():
record = {
"id": "F-1",
"asset": "web01",
"type": "Vulnerability",
"severity": "CRITICAL",
"status": "OPEN",
"score": 9.8
}
normalized = (
normalize_finding(
record
)
)
assert (
normalized[
"asset"
]
== "WEB01"
)
assert (
normalized[
"severity"
]
== "critical"
)
def test_missing_field():
record = {
"id": "F-1"
}
try:
normalize_finding(
record
)
except ValueError:
assert True
else:
assert False

A score:

15.0

should be rejected when the expected range is:

0–10

Provide:

F-1001
F-1001

Expected:

UNIQUE = 1
DUPLICATE = 1

Before collecting:

FINDINGS

call:

/api/health

If unavailable:

STOP CLEANLY

Instead of hard-coding:

BASE_URL

use:

data/config.json

with no secrets inside it.

Use:

argparse

to support:

--base-url
--status
--severity
--output

Instead of human-only logs:

2026... ERROR ...

produce optional:

{
"level": "error",
"event": "api_request_failed",
"status": 503
}

Cache:

/api/findings

for:

60 seconds

Then compare:

API REQUEST COUNT

before and after caching.

143 β€” Challenge 06 β€” Add Retry-After Support

Section titled β€œ143 β€” Challenge 06 β€” Add Retry-After Support”

When:

429

is returned, inspect:

Retry-After

and wait accordingly.

Produce:

api-metrics.json

containing:

requests
successes
failures
retries
records_processed
duration

Architecture:

API
↓
PYTHON
↓
NORMALIZE
↓
SQLITE
↓
SQL ANALYTICS

This connects:

Lab 05
+
Lab 07

146 β€” Challenge 09 β€” Combine Vulnerability Prioritization

Section titled β€œ146 β€” Challenge 09 β€” Combine Vulnerability Prioritization”

Feed API results into the prioritization logic from:

Lab 06

Architecture:

SECURITY API
↓
FINDINGS
↓
NORMALIZE
↓
ASSET CONTEXT
↓
RISK PRIORITY
↓
REMEDIATION QUEUE

For any simulated write operation:

PREVIEW ACTION
↓
ASK FOR APPROVAL
↓
POST
↓
VERIFY RESPONSE

Before integrating a real security API, review:

  • API endpoint approved
  • HTTPS used
  • TLS verification enabled
  • Authentication method understood
  • Least-privilege token used
  • Secrets not hard-coded
  • Token rotation supported
  • Timeouts configured
  • Errors handled
  • Retries limited
  • Backoff implemented
  • 429 handling understood
  • Pagination implemented
  • JSON validated
  • Schema validated
  • Data normalized
  • Duplicate handling implemented
  • Sensitive fields minimized
  • Logs exclude secrets
  • API writes governed
  • Response verified
  • Data retention understood

Avoid:

HARDCODED API KEYS
NO TIMEOUT
INFINITE RETRIES
RETRYING 401 ERRORS
IGNORING 429
NO PAGINATION
TRUSTING JSON BLINDLY
NO SCHEMA VALIDATION
NO NORMALIZATION
LOGGING AUTHORIZATION HEADERS
COLLECTING TOO MUCH DATA
USING ADMIN TOKENS FOR READ-ONLY JOBS
AUTOMATICALLY EXECUTING HIGH-IMPACT ACTIONS
NO AUDIT LOG
NO RESPONSE VERIFICATION

Do not think:

API
=
JUST ANOTHER DATA SOURCE

An API can be:

A PRIVILEGED CONTROL PLANE

It may be able to:

DISABLE ACCOUNTS
ISOLATE ENDPOINTS
CHANGE FIREWALLS
DELETE RESOURCES
CREATE INCIDENTS
MODIFY SECURITY POLICIES

That means API permissions require serious governance.

A strong security automation principle is:

READ
↓
UNDERSTAND
↓
VALIDATE
↓
PREVIEW
↓
APPROVE
↓
WRITE
↓
VERIFY

When an API request fails:

WHAT FAILED?
↓
NETWORK?
↓
AUTH?
↓
PERMISSION?
↓
RATE LIMIT?
↓
SERVER?
↓
BAD REQUEST?
↓
BAD DATA?

Do not treat every failure identically.

REQUEST
↓
TIMEOUT
↓
STATUS
↓
HEADERS
↓
BODY
↓
JSON
↓
SCHEMA
↓
NORMALIZE
↓
ANALYZE

Your completed project should look like:

security-api-integration/
|
+-- api/
| +-- training_api.py
|
+-- data/
|
+-- src/
| +-- security_api_client.py
|
+-- tests/
| +-- test_security_api_client.py
|
+-- reports/
| +-- raw-api-response.json
| +-- api-findings.csv
| +-- invalid-api-records.json
| +-- api-summary.json
| +-- security-api-report.md
|
+-- README.md
|
+-- architecture.md

Include:

PROJECT OVERVIEW
SECURITY USE CASE
ARCHITECTURE
TRAINING API
AUTHENTICATION
ENVIRONMENT VARIABLES
HTTP METHODS
ERROR HANDLING
RETRY STRATEGY
RATE LIMITING
PAGINATION
NORMALIZATION
REPORTS
TESTING
SECURITY CONSIDERATIONS
LIMITATIONS

Your local training API does not provide:

REAL TLS
OAUTH 2.0
SHORT-LIVED TOKENS
ENTERPRISE RATE LIMITS
DISTRIBUTED PAGINATION
WEBHOOKS
PRODUCTION SECURITY DATA
HIGH AVAILABILITY
REAL RBAC

These are future learning areas.

Confirm:

  • Lab workspace created
  • Local training API created
  • Synthetic findings created
  • Bearer authentication implemented
  • Health endpoint tested
  • Unauthorized request tested
  • GET request completed
  • Query parameters used
  • HTTP status codes reviewed
  • JSON parsed
  • JSON validation implemented
  • Client token moved to environment variable
  • Timeout configured
  • HTTP errors handled
  • Network errors handled
  • Retry logic implemented
  • Backoff implemented
  • Rate limiting understood
  • Pagination implemented
  • All paginated records collected
  • API findings normalized
  • Missing fields rejected
  • Invalid scores rejected
  • Duplicate logic implemented
  • Raw API snapshot exported
  • Normalized CSV exported
  • Invalid records exported
  • Summary JSON exported
  • Markdown report generated
  • Controlled local POST tested
  • No real credentials used
  • Authorization headers excluded from logs
  • No destructive API automation created
  • README created
  • Limitations documented

You started with:

SECURITY DATA
INSIDE AN API

You built:

PYTHON CLIENT
↓
AUTHENTICATION
↓
GET REQUEST
↓
TIMEOUT
↓
STATUS VALIDATION
↓
JSON PARSING
↓
SCHEMA VALIDATION
↓
NORMALIZATION
↓
PAGINATION
↓
RETRY / BACKOFF
↓
SECURITY ANALYSIS
↓
REPORT

You now have a reusable security API integration pattern capable of handling:

AUTHENTICATION
QUERY PARAMETERS
JSON RESPONSES
STATUS CODES
TIMEOUTS
ERRORS
RETRIES
RATE LIMITS
PAGINATION
NORMALIZATION
CSV / JSON REPORTING

The central lesson is:

A SUCCESSFUL HTTP REQUEST
DOES NOT AUTOMATICALLY MEAN
A SUCCESSFUL SECURITY WORKFLOW

You must validate:

STATUS
CONTENT
SCHEMA
DATA QUALITY
AUTHORIZATION
CONTEXT

Whenever you integrate with a security API, think:

WHAT API AM I CALLING?
↓
WHAT PERMISSIONS DO I NEED?
↓
HOW WILL I AUTHENTICATE?
↓
IS THE CONNECTION TRUSTED?
↓
WHAT IS MY TIMEOUT?
↓
WHAT IF IT FAILS?
↓
SHOULD I RETRY?
↓
IS THERE A RATE LIMIT?
↓
IS THERE PAGINATION?
↓
IS THE JSON VALID?
↓
IS THE SCHEMA VALID?
↓
HOW WILL I NORMALIZE IT?
↓
WHAT SECURITY DECISION
WILL USE THIS DATA?

The goal is not:

CALL MORE APIS

The goal is:

BUILD RELIABLE,
CONTROLLED,
AUDITABLE
SECURITY INTEGRATIONS

➑️ Lab 08 β€” Cloud Security Configuration Auditor

The next lab takes your Python automation and API skills into cloud security.

You will build a read-only cloud security assessment workflow for an authorized lab account.

The architecture will be:

CLOUD ACCOUNT
↓
CLOUD API
↓
SECURITY AUDITOR
↓
IDENTITY REVIEW
↓
LOGGING REVIEW
↓
ENCRYPTION REVIEW
↓
PUBLIC EXPOSURE REVIEW
↓
SECURITY CONTROL REVIEW
↓
NORMALIZED FINDINGS
↓
SECURITY REPORT

You will learn how to turn cloud configuration data into repeatable security findings without making disruptive changes to cloud resources.