Skip to content

Lab 05 β€” SQL Security Analytics Lab

Difficulty: Intermediate
Estimated Time: 120–150 minutes
Primary Language: SQL
Security Domain: SOC / Vulnerability Management / Identity Security / GRC / Security Analytics
Environment: Local SQLite lab
Automation Type: Defensive Security Data Analysis

Your task is to build and investigate a synthetic enterprise security database.

You will create tables representing:

USERS
ASSETS
LOGIN EVENTS
VULNERABILITIES
INCIDENTS

Then you will use SQL to answer practical cybersecurity questions.

Examples:

WHO HAS THE MOST FAILED LOGINS?
WHICH SOURCE IP TARGETED
THE MOST ACCOUNTS?
WHICH ADMINISTRATORS
DO NOT HAVE MFA?
WHICH CRITICAL ASSETS
HAVE OPEN CRITICAL VULNERABILITIES?
WHICH ASSETS
HAVE NO OWNER?
WHICH INCIDENTS
REMAIN UNASSIGNED?

The workflow will be:

SECURITY QUESTION
↓
IDENTIFY TABLES
↓
SELECT
↓
FILTER
↓
JOIN
↓
GROUP
↓
AGGREGATE
↓
INTERPRET
↓
REPORT

Security platforms store enormous amounts of structured information.

Examples include:

SIEM EVENTS
IDENTITY DATA
ASSET INVENTORIES
VULNERABILITY FINDINGS
INCIDENT RECORDS
CLOUD SECURITY FINDINGS
COMPLIANCE EVIDENCE

SQL allows security professionals to move from:

MILLIONS OF RECORDS

to:

ONE ANSWER

For example:

Which privileged accounts
do not have MFA?

or:

Which critical production assets
have unresolved critical findings?

These are security questions.

SQL is simply the tool used to answer them.

By completing this lab, you should be able to:

CREATE A SECURITY DATABASE
CREATE TABLES
INSERT SYNTHETIC DATA
USE SELECT
USE WHERE
USE AND / OR
USE IN
USE LIKE
USE ORDER BY
USE LIMIT
USE DISTINCT
USE COUNT
USE GROUP BY
USE HAVING
USE INNER JOIN
USE LEFT JOIN
USE CASE
USE SUBQUERIES
USE COMMON TABLE EXPRESSIONS
CORRELATE USERS AND EVENTS
CORRELATE ASSETS AND VULNERABILITIES
BUILD SECURITY METRICS
GENERATE ANALYST FINDINGS
SECURITY DATABASE
↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
↓ ↓ ↓
USERS ASSETS LOGIN EVENTS
↓ ↓ ↓
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
↓
VULNERABILITIES
↓
INCIDENTS
↓
SQL
↓
FILTER / JOIN / GROUP
↓
SECURITY INSIGHT
↓
ANALYST REPORT

This lab uses:

SYNTHETIC SECURITY DATA

Do not run modification queries against production security databases unless you are specifically authorized.

For learning and investigation, prefer:

READ-ONLY QUERIES

For this lab, use:

SQLite

Why?

FREE
LIGHTWEIGHT
LOCAL
NO SERVER REQUIRED
PORTABLE
EASY TO RESET

Run:

Terminal window
sqlite3 --version

If available, you are ready.

Alternatively, you can use:

DB Browser for SQLite
VS Code SQLite extension
Python sqlite3 module

Create:

sql-security-analytics/
|
+-- database/
|
+-- queries/
|
+-- reports/
|
+-- README.md

Linux/macOS:

Terminal window
mkdir -p sql-security-analytics/{database,queries,reports}
cd sql-security-analytics

PowerShell:

Terminal window
mkdir sql-security-analytics
cd sql-security-analytics
mkdir database
mkdir queries
mkdir reports

Run:

Terminal window
sqlite3 database/security-lab.db

You should enter the SQLite prompt:

sqlite>

Inside SQLite:

.headers on

Run:

.mode column

Run:

CREATE TABLE users (
user_id INTEGER PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
department TEXT,
role TEXT,
privileged INTEGER NOT NULL DEFAULT 0,
mfa_enabled INTEGER NOT NULL DEFAULT 0,
account_status TEXT NOT NULL
);

Fields:

user_id
Unique user identifier
username
Account name
department
Business department
role
Job role
privileged
0 = standard
1 = privileged
mfa_enabled
0 = no
1 = yes
account_status
active / disabled / locked

Run:

INSERT INTO users
(
user_id,
username,
department,
role,
privileged,
mfa_enabled,
account_status
)
VALUES
(1, 'admin01', 'IT', 'Systems Administrator', 1, 1, 'active'),
(2, 'admin02', 'IT', 'Cloud Administrator', 1, 0, 'active'),
(3, 'analyst01', 'Security', 'SOC Analyst', 1, 1, 'active'),
(4, 'user01', 'Finance', 'Accountant', 0, 1, 'active'),
(5, 'user02', 'HR', 'HR Specialist', 0, 0, 'active'),
(6, 'user03', 'Sales', 'Sales Executive', 0, 1, 'active'),
(7, 'service_backup', 'IT', 'Service Account', 1, 0, 'active'),
(8, 'former_user', 'Sales', 'Former Employee', 0, 0, 'disabled');

Run:

SELECT *
FROM users;

Run:

CREATE TABLE assets (
asset_id INTEGER PRIMARY KEY,
hostname TEXT NOT NULL UNIQUE,
ip_address TEXT NOT NULL,
operating_system TEXT,
environment TEXT,
criticality TEXT,
owner TEXT,
internet_exposed INTEGER NOT NULL DEFAULT 0
);

Run:

INSERT INTO assets
(
asset_id,
hostname,
ip_address,
operating_system,
environment,
criticality,
owner,
internet_exposed
)
VALUES
(1, 'DC01', '10.10.10.10', 'Windows Server', 'Production', 'Critical', 'Infrastructure Team', 0),
(2, 'WEB01', '10.10.20.10', 'Ubuntu Linux', 'Production', 'Critical', 'Web Team', 1),
(3, 'APP01', '10.10.20.20', 'Ubuntu Linux', 'Production', 'High', 'Application Team', 0),
(4, 'DB01', '10.10.30.10', 'Windows Server', 'Production', 'Critical', 'Database Team', 0),
(5, 'DEV01', '10.10.40.10', 'Ubuntu Linux', 'Development', 'Medium', 'Development Team', 0),
(6, 'OLD01', '10.10.50.10', 'Windows Server', 'Production', 'High', NULL, 0);

Run:

SELECT *
FROM assets;

Run:

CREATE TABLE login_events (
event_id INTEGER PRIMARY KEY,
event_time TEXT NOT NULL,
username TEXT NOT NULL,
source_ip TEXT NOT NULL,
target_asset_id INTEGER,
status TEXT NOT NULL,
authentication_type TEXT,
FOREIGN KEY (target_asset_id)
REFERENCES assets(asset_id)
);

Run:

INSERT INTO login_events
(
event_id,
event_time,
username,
source_ip,
target_asset_id,
status,
authentication_type
)
VALUES
(1, '2026-08-29T09:00:01Z', 'admin01', '10.10.99.10', 1, 'failed', 'password'),
(2, '2026-08-29T09:00:15Z', 'admin01', '10.10.99.10', 1, 'failed', 'password'),
(3, '2026-08-29T09:00:31Z', 'admin01', '10.10.99.10', 1, 'failed', 'password'),
(4, '2026-08-29T09:01:05Z', 'admin01', '10.10.99.10', 1, 'success', 'password'),
(5, '2026-08-29T09:02:10Z', 'user01', '10.10.88.20', 2, 'failed', 'password'),
(6, '2026-08-29T09:02:15Z', 'user02', '10.10.88.20', 2, 'failed', 'password'),
(7, '2026-08-29T09:02:20Z', 'user03', '10.10.88.20', 2, 'failed', 'password'),
(8, '2026-08-29T09:02:25Z', 'admin02', '10.10.88.20', 2, 'failed', 'password'),
(9, '2026-08-29T09:03:10Z', 'user01', '10.10.60.30', 3, 'failed', 'password'),
(10, '2026-08-29T09:03:20Z', 'user01', '10.10.60.30', 3, 'failed', 'password'),
(11, '2026-08-29T09:03:30Z', 'user01', '10.10.60.30', 3, 'failed', 'password'),
(12, '2026-08-29T09:04:00Z', 'analyst01', '10.10.70.20', 1, 'success', 'mfa'),
(13, '2026-08-29T09:05:00Z', 'admin02', '10.10.70.21', 4, 'success', 'password'),
(14, '2026-08-29T09:06:00Z', 'service_backup', '10.10.30.50', 4, 'success', 'service'),
(15, '2026-08-29T09:07:00Z', 'former_user', '10.10.90.50', 2, 'failed', 'password');

Run:

CREATE TABLE vulnerabilities (
vulnerability_id INTEGER PRIMARY KEY,
asset_id INTEGER NOT NULL,
finding_name TEXT NOT NULL,
severity TEXT NOT NULL,
cvss REAL,
status TEXT NOT NULL,
discovered_date TEXT,
remediation_due_date TEXT,
FOREIGN KEY (asset_id)
REFERENCES assets(asset_id)
);

Run:

INSERT INTO vulnerabilities
(
vulnerability_id,
asset_id,
finding_name,
severity,
cvss,
status,
discovered_date,
remediation_due_date
)
VALUES
(1, 2, 'Outdated Web Framework', 'Critical', 9.8, 'Open', '2026-08-01', '2026-08-15'),
(2, 2, 'Weak TLS Configuration', 'High', 8.1, 'Open', '2026-08-02', '2026-08-20'),
(3, 1, 'Missing Security Update', 'Critical', 9.5, 'Open', '2026-08-03', '2026-08-17'),
(4, 3, 'Outdated Application Library', 'High', 7.8, 'Open', '2026-08-05', '2026-08-25'),
(5, 4, 'Database Configuration Review', 'Medium', 5.3, 'Open', '2026-08-06', '2026-09-05'),
(6, 5, 'Development Package Finding', 'Medium', 5.0, 'Closed', '2026-08-07', '2026-09-07'),
(7, 6, 'Legacy Service Exposure', 'Critical', 9.0, 'Open', '2026-07-15', '2026-07-30');

Run:

CREATE TABLE incidents (
incident_id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
severity TEXT NOT NULL,
status TEXT NOT NULL,
assigned_to TEXT,
related_asset_id INTEGER,
opened_time TEXT NOT NULL,
FOREIGN KEY (related_asset_id)
REFERENCES assets(asset_id)
);

Run:

INSERT INTO incidents
(
incident_id,
title,
severity,
status,
assigned_to,
related_asset_id,
opened_time
)
VALUES
(1, 'Repeated Administrative Login Failures', 'High', 'Open', 'analyst01', 1, '2026-08-29T09:01:00Z'),
(2, 'Web Server Security Alert', 'Critical', 'Open', NULL, 2, '2026-08-29T09:10:00Z'),
(3, 'Database Authentication Review', 'Medium', 'Investigating', 'analyst01', 4, '2026-08-29T09:15:00Z'),
(4, 'Legacy Server Risk Review', 'High', 'Open', NULL, 6, '2026-08-29T09:20:00Z');

Run:

.tables

Expected:

assets
incidents
login_events
users
vulnerabilities

Ask:

Which users are privileged?

Run:

SELECT
username,
department,
role
FROM users
WHERE privileged = 1;

Security question:

Which privileged accounts
do not have MFA?

Run:

SELECT
username,
department,
role,
account_status
FROM users
WHERE privileged = 1
AND mfa_enabled = 0;

Expected candidates include:

admin02
service_backup

Do not automatically conclude:

SECURITY INCIDENT

Instead ask:

SHOULD THIS ACCOUNT SUPPORT MFA?
IS IT INTERACTIVE?
IS IT A SERVICE ACCOUNT?
WHAT OTHER CONTROLS EXIST?
IS THERE A DOCUMENTED EXCEPTION?

Improve the query:

SELECT
username,
role
FROM users
WHERE privileged = 1
AND mfa_enabled = 0
AND account_status = 'active';

Run:

SELECT
COUNT(*) AS privileged_accounts
FROM users
WHERE privileged = 1;

Run:

SELECT
COUNT(*) AS active_users,
SUM(
CASE
WHEN mfa_enabled = 1
THEN 1
ELSE 0
END
) AS mfa_enabled_users
FROM users
WHERE account_status = 'active';

Run:

SELECT
COUNT(*) AS active_users,
SUM(
CASE
WHEN mfa_enabled = 1
THEN 1
ELSE 0
END
) AS mfa_enabled_users,
ROUND(
100.0 *
SUM(
CASE
WHEN mfa_enabled = 1
THEN 1
ELSE 0
END
)
/ COUNT(*),
2
) AS mfa_percentage
FROM users
WHERE account_status = 'active';

Run:

SELECT
username,
department,
role
FROM users
WHERE account_status = 'disabled';

A disabled account is not necessarily a problem.

But review:

DOES IT STILL HAVE APPLICATION ACCESS?
DOES IT OWN RESOURCES?
IS IT STILL IN PRIVILEGED GROUPS?
SHOULD IT BE REMOVED?

Run:

SELECT
username,
COUNT(*) AS failed_logins
FROM login_events
WHERE status = 'failed'
GROUP BY username
ORDER BY failed_logins DESC;

Use:

SELECT
username,
COUNT(*) AS failed_logins
FROM login_events
WHERE status = 'failed'
GROUP BY username
HAVING COUNT(*) >= 3
ORDER BY failed_logins DESC;

WHERE filters:

ROWS

before aggregation.

HAVING filters:

GROUPS

after aggregation.

Mental model:

WHERE
↓
GROUP
↓
HAVING

Run:

SELECT
source_ip,
COUNT(*) AS failed_logins
FROM login_events
WHERE status = 'failed'
GROUP BY source_ip
ORDER BY failed_logins DESC;

Run:

SELECT
source_ip,
COUNT(*) AS failed_logins
FROM login_events
WHERE status = 'failed'
GROUP BY source_ip
HAVING COUNT(*) >= 3
ORDER BY failed_logins DESC;

Run:

SELECT
source_ip,
COUNT(
DISTINCT username
) AS unique_users
FROM login_events
WHERE status = 'failed'
GROUP BY source_ip
ORDER BY unique_users DESC;

Run:

SELECT
source_ip,
COUNT(*) AS failed_logins,
COUNT(
DISTINCT username
) AS unique_users
FROM login_events
WHERE status = 'failed'
GROUP BY source_ip
HAVING COUNT(
DISTINCT username
) >= 3
ORDER BY unique_users DESC;

One source targeting multiple accounts may deserve review.

However, legitimate explanations can include:

VPN GATEWAY
NAT DEVICE
PROXY
SHARED APPLICATION
AUTOMATION

Detection:

PATTERN

is not the same as:

CONCLUSION

Run:

SELECT
l.event_time,
l.username,
l.source_ip,
l.status,
a.hostname,
a.criticality
FROM login_events AS l
INNER JOIN assets AS a
ON l.target_asset_id = a.asset_id
ORDER BY l.event_time;

Before:

target_asset_id = 1

After:

DC01
Critical

Security context becomes much stronger.

Run:

SELECT
l.username,
l.source_ip,
a.hostname,
a.criticality,
COUNT(*) AS failed_logins
FROM login_events AS l
INNER JOIN assets AS a
ON l.target_asset_id = a.asset_id
WHERE l.status = 'failed'
AND a.criticality = 'Critical'
GROUP BY
l.username,
l.source_ip,
a.hostname,
a.criticality
ORDER BY failed_logins DESC;

Run:

SELECT
l.event_time,
l.username,
u.role,
u.privileged,
u.mfa_enabled,
l.source_ip,
l.status
FROM login_events AS l
LEFT JOIN users AS u
ON l.username = u.username
ORDER BY l.event_time;

LEFT JOIN retains every login event even if:

USERNAME IS NOT
IN THE USER INVENTORY

That can be security-relevant.

Run:

SELECT DISTINCT
l.username
FROM login_events AS l
LEFT JOIN users AS u
ON l.username = u.username
WHERE u.user_id IS NULL;

In a larger dataset this might identify:

UNMANAGED ACCOUNTS
DATA QUALITY ISSUES
OLD IDENTITIES
UNEXPECTED IDENTITIES

Run:

SELECT
l.event_time,
l.username,
l.source_ip,
l.status
FROM login_events AS l
INNER JOIN users AS u
ON l.username = u.username
WHERE u.account_status = 'disabled';

Possible explanations include:

STALE APPLICATION CREDENTIAL
OLD DEVICE
ACCOUNT CLEANUP ISSUE
EXPECTED DENIED LOGIN
UNAUTHORIZED ATTEMPT

Run:

SELECT
l.event_time,
l.username,
u.role,
l.source_ip,
l.authentication_type,
a.hostname
FROM login_events AS l
INNER JOIN users AS u
ON l.username = u.username
LEFT JOIN assets AS a
ON l.target_asset_id = a.asset_id
WHERE l.status = 'success'
AND u.privileged = 1
AND u.mfa_enabled = 0;

For:

service_backup

MFA may not be applicable if it is:

NON-INTERACTIVE

This is why security analytics needs:

BUSINESS CONTEXT

Run:

SELECT
l.event_time,
l.username,
l.source_ip,
a.hostname,
l.authentication_type
FROM login_events AS l
INNER JOIN users AS u
ON l.username = u.username
LEFT JOIN assets AS a
ON l.target_asset_id = a.asset_id
WHERE l.status = 'success'
AND u.privileged = 1
ORDER BY l.event_time;

A useful investigation pattern:

MULTIPLE FAILURES
↓
SUCCESS

SQL can identify candidates.

Run:

SELECT
username,
SUM(
CASE
WHEN status = 'failed'
THEN 1
ELSE 0
END
) AS failures,
SUM(
CASE
WHEN status = 'success'
THEN 1
ELSE 0
END
) AS successes
FROM login_events
GROUP BY username
HAVING failures >= 3
AND successes >= 1;

This query does not prove:

FAILURES OCCURRED
BEFORE
THE SUCCESS

It only proves both statuses exist.

Timeline matters.

Run:

SELECT
event_time,
username,
source_ip,
status,
authentication_type
FROM login_events
WHERE username = 'admin01'
ORDER BY event_time;

Now manually review sequence.

Aggregation gives:

SUMMARY

Timeline gives:

SEQUENCE

Both matter.

Run:

SELECT
vulnerability_id,
finding_name,
severity,
cvss,
status
FROM vulnerabilities
ORDER BY cvss DESC;

Run:

SELECT
finding_name,
severity,
cvss,
status
FROM vulnerabilities
WHERE status = 'Open'
ORDER BY cvss DESC;

Run:

SELECT
vulnerability_id,
finding_name,
cvss
FROM vulnerabilities
WHERE severity = 'Critical'
AND status = 'Open'
ORDER BY cvss DESC;

Run:

SELECT
a.hostname,
a.environment,
a.criticality,
v.finding_name,
v.severity,
v.cvss,
v.status
FROM vulnerabilities AS v
INNER JOIN assets AS a
ON v.asset_id = a.asset_id
ORDER BY v.cvss DESC;

Run:

SELECT
a.hostname,
a.criticality,
a.environment,
a.internet_exposed,
v.finding_name,
v.cvss,
v.remediation_due_date
FROM vulnerabilities AS v
INNER JOIN assets AS a
ON v.asset_id = a.asset_id
WHERE v.status = 'Open'
AND v.severity = 'Critical'
AND a.criticality = 'Critical'
ORDER BY v.cvss DESC;

Compare:

Critical vulnerability
on isolated development VM

with:

Critical vulnerability
on internet-facing production server

Technical severity may be the same.

Risk context is not.

Run:

SELECT
a.hostname,
a.ip_address,
a.criticality,
v.finding_name,
v.severity,
v.cvss
FROM assets AS a
INNER JOIN vulnerabilities AS v
ON a.asset_id = v.asset_id
WHERE a.internet_exposed = 1
AND v.status = 'Open'
ORDER BY v.cvss DESC;

Run:

SELECT
a.hostname,
COUNT(
v.vulnerability_id
) AS finding_count
FROM assets AS a
LEFT JOIN vulnerabilities AS v
ON a.asset_id = v.asset_id
GROUP BY a.hostname
ORDER BY finding_count DESC;

It includes assets with:

ZERO FINDINGS

Those assets are still part of inventory.

Run:

SELECT
a.hostname,
COUNT(
v.vulnerability_id
) AS open_findings
FROM assets AS a
LEFT JOIN vulnerabilities AS v
ON a.asset_id = v.asset_id
AND v.status = 'Open'
GROUP BY a.hostname
ORDER BY open_findings DESC;

Run:

SELECT
hostname,
ip_address,
environment,
criticality
FROM assets
WHERE owner IS NULL
OR TRIM(owner) = '';

Expected:

OLD01

Without an owner:

WHO PATCHES IT?
WHO APPROVES CHANGES?
WHO RESPONDS TO ALERTS?
WHO ACCEPTS RISK?

Asset ownership is a security control.

Run:

SELECT
a.hostname,
a.criticality,
v.finding_name,
v.severity,
v.status
FROM assets AS a
INNER JOIN vulnerabilities AS v
ON a.asset_id = v.asset_id
WHERE (
a.owner IS NULL
OR TRIM(a.owner) = ''
)
AND v.status = 'Open';

Because the lab uses:

2026-08-29

as the reference date, query:

SELECT
a.hostname,
v.finding_name,
v.severity,
v.remediation_due_date
FROM vulnerabilities AS v
INNER JOIN assets AS a
ON v.asset_id = a.asset_id
WHERE v.status = 'Open'
AND v.remediation_due_date < '2026-08-29'
ORDER BY v.remediation_due_date;

In a live SQLite database, you might use:

date('now')

Example:

WHERE remediation_due_date < date('now')

Use the lab’s fixed date when you want reproducible results.

Run:

SELECT
a.hostname,
a.owner,
v.finding_name,
v.cvss,
v.remediation_due_date
FROM vulnerabilities AS v
INNER JOIN assets AS a
ON v.asset_id = a.asset_id
WHERE v.status = 'Open'
AND v.severity = 'Critical'
AND v.remediation_due_date < '2026-08-29'
ORDER BY v.cvss DESC;

Run:

SELECT
a.hostname,
a.criticality,
a.internet_exposed,
v.finding_name,
v.severity,
v.cvss,
CASE
WHEN v.severity = 'Critical'
AND a.criticality = 'Critical'
AND a.internet_exposed = 1
THEN 'Priority 1'
WHEN v.severity = 'Critical'
AND a.criticality IN (
'Critical',
'High'
)
THEN 'Priority 2'
WHEN v.severity = 'High'
THEN 'Priority 3'
ELSE 'Standard Review'
END AS remediation_priority
FROM vulnerabilities AS v
INNER JOIN assets AS a
ON v.asset_id = a.asset_id
WHERE v.status = 'Open'
ORDER BY
remediation_priority,
v.cvss DESC;

This is a:

TRAINING MODEL

A production prioritization process may include:

EXPLOITABILITY
THREAT INTELLIGENCE
BUSINESS IMPACT
DATA SENSITIVITY
EXPOSURE
COMPENSATING CONTROLS
REMEDIATION AGE

Run:

SELECT *
FROM incidents
ORDER BY opened_time;

Run:

SELECT
incident_id,
title,
severity,
assigned_to
FROM incidents
WHERE status = 'Open';

Run:

SELECT
incident_id,
title,
severity,
opened_time
FROM incidents
WHERE assigned_to IS NULL;

Do not use:

assigned_to = NULL

Use:

assigned_to IS NULL

because NULL represents:

UNKNOWN / ABSENT

rather than a normal value.

Run:

SELECT
incident_id,
title,
severity,
opened_time
FROM incidents
WHERE assigned_to IS NULL
AND severity = 'Critical';

Run:

SELECT
i.incident_id,
i.title,
i.severity,
i.status,
i.assigned_to,
a.hostname,
a.criticality
FROM incidents AS i
LEFT JOIN assets AS a
ON i.related_asset_id = a.asset_id
ORDER BY i.opened_time;

Run:

SELECT
i.incident_id,
i.title,
i.severity,
a.hostname,
a.criticality
FROM incidents AS i
INNER JOIN assets AS a
ON i.related_asset_id = a.asset_id
WHERE i.assigned_to IS NULL
AND a.criticality = 'Critical';

Now start combining:

ASSET CRITICALITY
VULNERABILITIES
INCIDENTS
AUTHENTICATION

This is where SQL becomes very powerful.

80 β€” Assets with Both Incidents and Open Vulnerabilities

Section titled β€œ80 β€” Assets with Both Incidents and Open Vulnerabilities”

Run:

SELECT DISTINCT
a.hostname,
a.criticality
FROM assets AS a
INNER JOIN vulnerabilities AS v
ON a.asset_id = v.asset_id
INNER JOIN incidents AS i
ON a.asset_id = i.related_asset_id
WHERE v.status = 'Open'
AND i.status IN (
'Open',
'Investigating'
);

Run:

WITH finding_counts AS (
SELECT
asset_id,
COUNT(*) AS open_findings
FROM vulnerabilities
WHERE status = 'Open'
GROUP BY asset_id
),
incident_counts AS (
SELECT
related_asset_id AS asset_id,
COUNT(*) AS active_incidents
FROM incidents
WHERE status IN (
'Open',
'Investigating'
)
GROUP BY related_asset_id
)
SELECT
a.hostname,
a.criticality,
COALESCE(
f.open_findings,
0
) AS open_findings,
COALESCE(
i.active_incidents,
0
) AS active_incidents
FROM assets AS a
LEFT JOIN finding_counts AS f
ON a.asset_id = f.asset_id
LEFT JOIN incident_counts AS i
ON a.asset_id = i.asset_id
ORDER BY
active_incidents DESC,
open_findings DESC;

A:

COMMON TABLE EXPRESSION

uses:

WITH

to create temporary named query results.

Mental model:

COMPLEX QUESTION
↓
BREAK INTO SMALLER QUESTIONS
↓
COMBINE RESULTS

If an asset has no incident:

NULL

can become:

0

using:

COALESCE(value, 0)

This makes reports easier to interpret.

Build a high-level security summary:

SELECT
(SELECT COUNT(*) FROM users)
AS total_users,
(
SELECT COUNT(*)
FROM users
WHERE privileged = 1
) AS privileged_users,
(
SELECT COUNT(*)
FROM users
WHERE mfa_enabled = 0
AND account_status = 'active'
) AS active_users_without_mfa,
(
SELECT COUNT(*)
FROM login_events
WHERE status = 'failed'
) AS failed_logins,
(
SELECT COUNT(*)
FROM vulnerabilities
WHERE status = 'Open'
) AS open_vulnerabilities,
(
SELECT COUNT(*)
FROM vulnerabilities
WHERE status = 'Open'
AND severity = 'Critical'
) AS open_critical_vulnerabilities,
(
SELECT COUNT(*)
FROM incidents
WHERE status IN (
'Open',
'Investigating'
)
) AS active_incidents;

Suppose:

Open vulnerabilities = 6

That alone does not tell you:

SEVERITY
ASSET CRITICALITY
AGE
EXPOSURE
OWNERSHIP

Metrics are starting points.

Not conclusions.

Run:

SELECT
status,
COUNT(*) AS event_count
FROM login_events
GROUP BY status;

Run:

SELECT
authentication_type,
status,
COUNT(*) AS event_count
FROM login_events
GROUP BY
authentication_type,
status
ORDER BY
authentication_type,
status;

Run:

SELECT
u.department,
COUNT(*) AS failed_logins
FROM login_events AS l
INNER JOIN users AS u
ON l.username = u.username
WHERE l.status = 'failed'
GROUP BY u.department
ORDER BY failed_logins DESC;

Run:

SELECT
CASE
WHEN u.privileged = 1
THEN 'Privileged'
ELSE 'Standard'
END AS account_type,
COUNT(*) AS failed_logins
FROM login_events AS l
INNER JOIN users AS u
ON l.username = u.username
WHERE l.status = 'failed'
GROUP BY account_type;

90 β€” Security Question: Privileged Accounts with Failures

Section titled β€œ90 β€” Security Question: Privileged Accounts with Failures”

Run:

SELECT
l.username,
COUNT(*) AS failed_logins
FROM login_events AS l
INNER JOIN users AS u
ON l.username = u.username
WHERE l.status = 'failed'
AND u.privileged = 1
GROUP BY l.username
ORDER BY failed_logins DESC;

91 β€” Security Question: Critical Assets with Authentication Failures

Section titled β€œ91 β€” Security Question: Critical Assets with Authentication Failures”

Run:

SELECT
a.hostname,
COUNT(*) AS failed_logins
FROM login_events AS l
INNER JOIN assets AS a
ON l.target_asset_id = a.asset_id
WHERE l.status = 'failed'
AND a.criticality = 'Critical'
GROUP BY a.hostname
ORDER BY failed_logins DESC;

92 β€” Security Question: Internet-Exposed Critical Assets

Section titled β€œ92 β€” Security Question: Internet-Exposed Critical Assets”

Run:

SELECT
hostname,
ip_address,
operating_system,
owner
FROM assets
WHERE internet_exposed = 1
AND criticality = 'Critical';

93 β€” Security Question: Internet-Exposed Assets with Critical Findings

Section titled β€œ93 β€” Security Question: Internet-Exposed Assets with Critical Findings”

Run:

SELECT
a.hostname,
a.ip_address,
a.owner,
v.finding_name,
v.cvss
FROM assets AS a
INNER JOIN vulnerabilities AS v
ON a.asset_id = v.asset_id
WHERE a.internet_exposed = 1
AND v.status = 'Open'
AND v.severity = 'Critical';

94 β€” Security Question: High-Risk Asset Without Owner

Section titled β€œ94 β€” Security Question: High-Risk Asset Without Owner”

Run:

SELECT
hostname,
criticality,
environment,
ip_address
FROM assets
WHERE criticality IN (
'Critical',
'High'
)
AND (
owner IS NULL
OR TRIM(owner) = ''
);

Find users whose usernames appear in failed login events:

SELECT
username,
department,
role
FROM users
WHERE username IN (
SELECT DISTINCT username
FROM login_events
WHERE status = 'failed'
);

NULL values can make:

NOT IN

behave unexpectedly.

For many anti-match operations, consider:

NOT EXISTS

Find users with no authentication events:

SELECT
u.username,
u.department,
u.account_status
FROM users AS u
WHERE NOT EXISTS (
SELECT 1
FROM login_events AS l
WHERE l.username = u.username
);

An account with no observed usage may deserve review if it is:

PRIVILEGED
ENABLED
OLD
UNOWNED

But your login table may cover only a short time period.

Do not overinterpret limited data.

Find duplicated IP addresses:

SELECT
ip_address,
COUNT(*) AS asset_count
FROM assets
GROUP BY ip_address
HAVING COUNT(*) > 1;

Find duplicate usernames:

SELECT
username,
COUNT(*) AS user_count
FROM users
GROUP BY username
HAVING COUNT(*) > 1;

The schema’s:

UNIQUE

constraint should already prevent this.

Run:

SELECT
COUNT(*) AS unowned_assets
FROM assets
WHERE owner IS NULL
OR TRIM(owner) = '';

Run:

SELECT
severity,
COUNT(*) AS finding_count
FROM vulnerabilities
WHERE status = 'Open'
GROUP BY severity
ORDER BY finding_count DESC;

Alphabetical ordering is not security ordering.

Use:

SELECT
severity,
COUNT(*) AS finding_count
FROM vulnerabilities
WHERE status = 'Open'
GROUP BY severity
ORDER BY
CASE severity
WHEN 'Critical' THEN 1
WHEN 'High' THEN 2
WHEN 'Medium' THEN 3
WHEN 'Low' THEN 4
ELSE 5
END;

Run:

SELECT
severity,
COUNT(*) AS incident_count
FROM incidents
WHERE status IN (
'Open',
'Investigating'
)
GROUP BY severity;

Run:

SELECT
CASE
WHEN assigned_to IS NULL
THEN 'Unassigned'
ELSE assigned_to
END AS assignment,
COUNT(*) AS incident_count
FROM incidents
GROUP BY assignment
ORDER BY incident_count DESC;

Build:

SELECT
a.hostname,
a.criticality,
a.owner,
COUNT(
DISTINCT v.vulnerability_id
) AS open_findings,
COUNT(
DISTINCT i.incident_id
) AS active_incidents
FROM assets AS a
LEFT JOIN vulnerabilities AS v
ON a.asset_id = v.asset_id
AND v.status = 'Open'
LEFT JOIN incidents AS i
ON a.asset_id = i.related_asset_id
AND i.status IN (
'Open',
'Investigating'
)
GROUP BY
a.asset_id,
a.hostname,
a.criticality,
a.owner
ORDER BY
active_incidents DESC,
open_findings DESC;

When joining:

ONE ASSET

to:

MULTIPLE VULNERABILITIES

and:

MULTIPLE INCIDENTS

rows may multiply.

That is why the previous query uses:

COUNT(DISTINCT ...)

This is an important analytics skill.

Create:

queries/
|
+-- 01_identity_review.sql
|
+-- 02_authentication_analysis.sql
|
+-- 03_vulnerability_analysis.sql
|
+-- 04_asset_hygiene.sql
|
+-- 05_incident_analysis.sql
|
+-- 06_security_metrics.sql

Place queries such as:

Privileged accounts
Users without MFA
Privileged users without MFA
Disabled accounts
Unused accounts

inside:

01_identity_review.sql

Include:

Failed logins by user
Failed logins by IP
Unique users per source
Privileged login failures
Disabled-account attempts
Successful admin logins

Include:

Open findings
Critical findings
Critical assets with critical findings
Internet-exposed assets with findings
Overdue vulnerabilities
Prioritization query

Include:

Assets without owners
Duplicate IPs
Internet-exposed critical assets
Assets without findings
High-criticality unowned assets

Include:

Open incidents
Unassigned incidents
Critical unassigned incidents
Incidents by asset
Incidents on critical systems

Include:

MFA percentage
Failed login count
Open vulnerability count
Critical vulnerability count
Active incident count
Unowned asset count

Inside SQLite:

.headers on
.mode csv
.output reports/privileged-users-without-mfa.csv

Then run:

SELECT
username,
role
FROM users
WHERE privileged = 1
AND mfa_enabled = 0
AND account_status = 'active';

Restore output:

.output stdout

Use:

.output reports/failed-login-summary.csv

Then:

SELECT
username,
COUNT(*) AS failed_logins
FROM login_events
WHERE status = 'failed'
GROUP BY username
ORDER BY failed_logins DESC;

Return:

.output stdout

Create:

reports/vulnerability-priority.csv

using the prioritization query.

Create:

reports/security-analysis-report.md

Suggested structure:

# SQL Security Analytics Report
## Executive Summary
## Identity Findings
## Authentication Findings
## Vulnerability Findings
## Asset Hygiene Findings
## Incident Findings
## Recommended Follow-Up
## Limitations
Finding:
Privileged accounts without MFA
Evidence:
admin02
service_backup
Review:
Validate whether MFA is technically applicable
to each account and confirm documented
compensating controls for non-interactive
service identities.
Finding:
Repeated failed logins against admin01
Evidence:
3 failed events from 10.10.99.10,
followed by successful authentication.
Review:
Correlate with MFA, endpoint, VPN,
identity-provider, and administrative
activity before determining cause.
Finding:
Critical open vulnerability on WEB01
Context:
Production
Critical asset
Internet exposed
Review:
Prioritize remediation according to the
organization's vulnerability management
process and validate compensating controls.
Finding:
OLD01 has no documented owner.
Context:
Production
High criticality
Open critical vulnerability
Review:
Establish accountable ownership and determine
whether the system should remain operational.
Finding:
Critical web-server incident remains unassigned.
Review:
Validate incident ownership and escalation
according to SOC procedures.

Whenever you receive a security question:

QUESTION
↓
WHICH TABLE?
↓
WHICH ROWS?
↓
WHICH COLUMNS?
↓
NEED ANOTHER TABLE?
↓
JOIN
↓
GROUP?
↓
AGGREGATE?
↓
INTERPRET

Do not begin with:

SELECT *

for every investigation.

Ask:

WHICH COLUMNS
DO I ACTUALLY NEED?

Large tables may contain:

SENSITIVE DATA
LARGE PAYLOADS
UNNECESSARY COLUMNS

Prefer:

DATA MINIMIZATION

In security analytics, your default should often be:

SELECT

not:

UPDATE
DELETE

Never casually run:

UPDATE users
SET account_status = 'disabled';

That affects:

EVERY ROW

Compare:

UPDATE users
SET account_status = 'disabled';

with:

UPDATE users
SET account_status = 'disabled'
WHERE username = 'example-user';

Even then, modification should only happen with proper authorization and change control.

This:

DELETE FROM login_events;

removes:

EVERY EVENT

from the table.

Do not practice destructive commands against valuable datasets.

Conceptually:

BEGIN TRANSACTION;

Make an authorized change.

Review.

Then:

COMMIT;

or:

ROLLBACK;

For this lab, you do not need to modify the dataset after creation.

When applications construct SQL using user input, do not concatenate untrusted data into queries.

Conceptually avoid:

"SELECT ... WHERE username = '" + input + "'"

Use:

PARAMETERIZED QUERIES

through your application language.

It helps prevent:

SQL INJECTION

and improves query safety.

Application input should follow:

USER INPUT
↓
VALIDATION
↓
PARAMETERIZED QUERY
↓
DATABASE

not:

USER INPUT
↓
STRING CONCATENATION
↓
SQL EXECUTION

A reporting application may need:

SELECT

but not:

DROP TABLE
DELETE
CREATE USER
ADMINISTER DATABASE

Conceptually:

SECURITY_ANALYST
↓
SELECT
↓
SECURITY TABLES

not:

SECURITY_ANALYST
↓
FULL DATABASE ADMIN

Do not hard-code:

DATABASE USERNAME
DATABASE PASSWORD
CONNECTION STRING SECRETS

inside scripts or Git repositories.

Use approved:

SECRET MANAGEMENT

Security-sensitive databases should provide appropriate:

ACCESS LOGGING
QUERY AUDITING
ADMINISTRATIVE CHANGE LOGGING

according to organizational requirements.

Create a backup:

Terminal window
cp database/security-lab.db \
database/security-lab-backup.db

PowerShell:

Terminal window
Copy-Item `
".\database\security-lab.db" `
".\database\security-lab-backup.db"

Security data can be:

OPERATIONALLY IMPORTANT
AUDIT RELEVANT
INCIDENT RELEVANT

Backup design should include:

CONFIDENTIALITY
INTEGRITY
RECOVERY TESTING

Indexes can improve query performance.

Example:

CREATE INDEX idx_login_events_username
ON login_events(username);

Create another:

CREATE INDEX idx_login_events_source_ip
ON login_events(source_ip);
CREATE INDEX idx_vulnerabilities_asset
ON vulnerabilities(asset_id);

Without suitable indexes:

LARGE TABLE
↓
FULL SCAN
↓
SLOW QUERY

With suitable indexing:

QUERY
↓
INDEX
↓
FASTER LOOKUP

But indexes also consume storage and affect writes.

SQLite supports:

EXPLAIN QUERY PLAN
SELECT
username,
COUNT(*)
FROM login_events
WHERE status = 'failed'
GROUP BY username;

You do not need to become a database administrator.

But understand that query design affects performance.

Create:

CREATE VIEW privileged_users AS
SELECT
user_id,
username,
department,
role,
mfa_enabled,
account_status
FROM users
WHERE privileged = 1;

Query:

SELECT *
FROM privileged_users;

Views can:

SIMPLIFY COMPLEX QUERIES
STANDARDIZE REPORTING
LIMIT EXPOSED COLUMNS

depending on database platform and permissions.

Run:

CREATE VIEW open_vulnerability_details AS
SELECT
a.hostname,
a.criticality,
a.owner,
a.internet_exposed,
v.finding_name,
v.severity,
v.cvss,
v.remediation_due_date
FROM vulnerabilities AS v
INNER JOIN assets AS a
ON v.asset_id = a.asset_id
WHERE v.status = 'Open';

Run:

SELECT *
FROM open_vulnerability_details
ORDER BY cvss DESC;

Create:

CREATE VIEW security_priority_dashboard AS
SELECT
a.asset_id,
a.hostname,
a.criticality,
a.owner,
a.internet_exposed,
COUNT(
DISTINCT v.vulnerability_id
) AS open_findings,
COUNT(
DISTINCT i.incident_id
) AS active_incidents
FROM assets AS a
LEFT JOIN vulnerabilities AS v
ON a.asset_id = v.asset_id
AND v.status = 'Open'
LEFT JOIN incidents AS i
ON a.asset_id = i.related_asset_id
AND i.status IN (
'Open',
'Investigating'
)
GROUP BY
a.asset_id,
a.hostname,
a.criticality,
a.owner,
a.internet_exposed;

Run:

SELECT *
FROM security_priority_dashboard
ORDER BY
active_incidents DESC,
open_findings DESC;

Without copying an earlier query, determine:

Which active privileged user
without MFA had a successful login?

Expected thinking:

users
+
login_events
↓
JOIN

Determine:

Which source IP generated failed logins
against the largest number of unique users?

Use:

COUNT(DISTINCT ...)

Determine:

Which critical production assets
have overdue open vulnerabilities?

You need:

assets
+
vulnerabilities

Determine:

Which assets have both
open incidents
and
critical vulnerabilities?

Use:

assets
vulnerabilities
incidents

Determine:

Which active accounts have no MFA
and at least one failed login?

Determine:

Which assets have no documented owner
but still have active incidents?

Determine:

Which source IPs generated
only failed authentication
and no successful authentication?

Think about:

GROUP BY
HAVING

Determine:

Which user had three or more
failed logins and at least
one successful login?

Then manually inspect the timeline.

Determine:

Which open vulnerability
has the earliest overdue due date?

Create one query that returns:

HOSTNAME
CRITICALITY
OWNER
OPEN FINDINGS
CRITICAL FINDINGS
ACTIVE INCIDENTS

for every asset.

Your repository should contain:

sql-security-analytics/
|
+-- database/
| +-- security-lab.db
| +-- security-lab-backup.db
|
+-- queries/
| +-- 01_identity_review.sql
| +-- 02_authentication_analysis.sql
| +-- 03_vulnerability_analysis.sql
| +-- 04_asset_hygiene.sql
| +-- 05_incident_analysis.sql
| +-- 06_security_metrics.sql
|
+-- reports/
| +-- privileged-users-without-mfa.csv
| +-- failed-login-summary.csv
| +-- vulnerability-priority.csv
| +-- security-analysis-report.md
|
+-- README.md

Include:

PROJECT OVERVIEW
SECURITY USE CASE
DATABASE SCHEMA
TABLE RELATIONSHIPS
DATASET DESCRIPTION
HOW TO CREATE DATABASE
HOW TO RUN QUERIES
SECURITY QUESTIONS
SAMPLE FINDINGS
SECURITY CONSIDERATIONS
LIMITATIONS

Add:

USERS
|
| username
↓
LOGIN_EVENTS
ASSETS
|
+------β†’ LOGIN_EVENTS
|
+------β†’ VULNERABILITIES
|
+------β†’ INCIDENTS
USERS
|
|
↓
LOGIN EVENTS
|
↓
ASSETS
/ \
↓ ↓
VULNERABILITIES INCIDENTS

Your lab does not include:

REAL-TIME SIEM INGESTION
BILLIONS OF EVENTS
MULTI-TENANT SECURITY
FULL IDENTITY GOVERNANCE
CLOUD SECURITY TABLES
EDR TELEMETRY
THREAT INTELLIGENCE TABLES
ADVANCED TEMPORAL CORRELATION

Avoid:

SELECT * EVERYWHERE
NO WHERE FILTER
WRONG JOIN TYPE
JOINING ON WRONG COLUMN
IGNORING NULL VALUES
COUNTING DUPLICATED JOIN ROWS
NO DATA NORMALIZATION
ASSUMING METRICS EQUAL RISK
NO TIME CONTEXT
NO ASSET CONTEXT
NO IDENTITY CONTEXT
RUNNING WRITE QUERIES ON PRODUCTION
HARDCODING DATABASE CREDENTIALS

Before trusting any query:

SELECT SMALL SAMPLE
↓
MANUALLY COUNT
↓
RUN ANALYTICS QUERY
↓
COMPARE RESULTS

Manually review:

SELECT *
FROM login_events
WHERE source_ip = '10.10.88.20';

Count the usernames.

Then compare against:

SELECT
source_ip,
COUNT(DISTINCT username)
FROM login_events
WHERE source_ip = '10.10.88.20'
GROUP BY source_ip;

Never assume:

QUERY EXECUTED SUCCESSFULLY
=
QUERY ANSWER IS CORRECT

A syntactically correct query can still contain:

BAD LOGIC
BAD JOIN
WRONG FILTER
WRONG ASSUMPTION

Confirm:

  • SQLite environment prepared
  • Security database created
  • Users table created
  • Assets table created
  • Login events table created
  • Vulnerabilities table created
  • Incidents table created
  • Synthetic data inserted
  • Basic SELECT queries completed
  • WHERE filtering used
  • ORDER BY used
  • GROUP BY used
  • HAVING used
  • COUNT used
  • DISTINCT used
  • INNER JOIN used
  • LEFT JOIN used
  • CASE used
  • NULL values handled
  • Subquery used
  • CTE used
  • Privileged users reviewed
  • MFA gaps reviewed
  • Failed logins analyzed
  • Failed sources analyzed
  • Multi-user sources identified
  • Disabled-account activity reviewed
  • Vulnerabilities joined to assets
  • Critical findings identified
  • Overdue findings identified
  • Unowned assets identified
  • Unassigned incidents identified
  • Security metrics generated
  • Query results exported
  • Analyst report created
  • Queries manually validated
  • Database backup created
  • Destructive production queries avoided
  • Limitations documented

You started with:

SEPARATE SECURITY TABLES

containing:

USERS
ASSETS
AUTHENTICATION
VULNERABILITIES
INCIDENTS

You transformed those tables into:

SECURITY QUESTIONS
↓
SQL QUERIES
↓
FILTERING
↓
CORRELATION
↓
AGGREGATION
↓
SECURITY FINDINGS

You now have a local enterprise-style security analytics database capable of answering questions about:

IDENTITY SECURITY
MFA COVERAGE
AUTHENTICATION FAILURES
PRIVILEGED ACCESS
ASSET CRITICALITY
VULNERABILITY EXPOSURE
ASSET OWNERSHIP
INCIDENT OWNERSHIP

The most important lesson from this lab is:

SECURITY ANALYTICS
STARTS WITH
A QUESTION

Do not begin with:

WHAT QUERY CAN I WRITE?

Begin with:

WHAT SECURITY QUESTION
DO I NEED TO ANSWER?

Then identify:

WHICH DATA?
WHICH TABLES?
WHICH RELATIONSHIPS?
WHICH FILTERS?
WHICH TIMEFRAME?
WHICH CONTEXT?
SECURITY QUESTION
↓
IDENTIFY DATA
↓
SELECT
↓
FILTER
↓
JOIN
↓
GROUP
↓
AGGREGATE
↓
VALIDATE
↓
INTERPRET
↓
SECURITY DECISION

SQL alone does not create security insight.

The real equation is:

STRUCTURED DATA
+
SQL
+
SECURITY CONTEXT
+
ANALYST JUDGMENT
=
ACTIONABLE SECURITY INSIGHT

➑️ Lab 06 β€” Vulnerability Data Analysis and Prioritization

The next lab takes the vulnerability concepts from this SQL lab and turns them into a complete Python-based security workflow.

You will build:

VULNERABILITY SCANNER EXPORT
↓
PYTHON
↓
VALIDATE
↓
NORMALIZE
↓
DEDUPLICATE
↓
ASSET CONTEXT
↓
RISK PRIORITIZATION
↓
OWNER MAPPING
↓
REMEDIATION REPORT

You will work with:

SEVERITY
CVSS
ASSET CRITICALITY
EXPOSURE
OWNERSHIP
REMEDIATION DUE DATES
DUPLICATE FINDINGS

and convert raw vulnerability data into an analyst-ready remediation queue.