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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ installed (see `owasp-wtf doctor`):

| Layer | Tool | OWASP categories |
|-------|------|------------------|
| Native rules (bundled) | regex rules | A01, A02, A03, A05, A07, A09 |
| Native rules (bundled) | regex rules | A01, A02, A03, A05, A07, A09, A10 |
| SAST | [Semgrep](https://semgrep.dev) | A01–A05, A07, A08 |
| Secrets | [Gitleaks](https://github.com/gitleaks/gitleaks) | A02, A05 |
| Dependencies / IaC | [Trivy](https://trivy.dev) | A05, A06, A08 |
Expand Down
121 changes: 121 additions & 0 deletions packages/cli/src/rules/a10-ssrf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// owasp-wtf-ignore-file — rule definitions, not actual vulnerabilities
import type { Rule, Finding } from '../types.js';

function snippet(lines: string[], idx: number): string {
const start = Math.max(0, idx - 2);
const end = Math.min(lines.length - 1, idx + 2);
const result: string[] = [];
for (let i = start; i <= end; i++) {
const marker = i === idx ? '>' : ' ';
result.push(`${marker} ${i + 1} | ${lines[i]}`);
}
return result.join('\n');
}

function isComment(line: string): boolean {
const trimmed = line.trim();
return (
trimmed.startsWith('//') ||
trimmed.startsWith('#') ||
trimmed.startsWith('*') ||
trimmed.startsWith('/*') ||
trimmed.startsWith('<!--')
);
}

// ---------------------------------------------------------------------------
// Rule: Server-Side Request Forgery (SSRF)
// ---------------------------------------------------------------------------
const ssrf: Rule = {
id: 'A10-SSRF',
name: 'Server-Side Request Forgery',
severity: 'high',
category: 'A10:2021-SSRF',
description:
'Passing user-controlled URLs directly to HTTP client libraries allows attackers to make requests to internal or restricted services.',
detect(content: string, filePath: string): Finding[] {
const findings: Finding[] = [];
const lines = content.split('\n');

const jsPatterns: { pattern: RegExp; label: string; suggestion: string }[] = [
{
pattern: /\bfetch\s*\(\s*req\.(params|query|body)/,
label: 'fetch() called with user-controlled URL',
suggestion: 'Validate and whitelist URLs before fetching. Use an allowlist of permitted destinations.',
},
{
pattern: /axios\.(get|post|put|delete|patch)\s*\(\s*req\.(params|query|body)/,
label: 'axios called with user-controlled URL',
suggestion: 'Validate URLs against an allowlist before making HTTP requests.',
},
{
pattern: /\b(?:http|https)\.get\s*\(\s*req\.(params|query|body)/,
label: 'http.get/https.get called with user-controlled URL',
suggestion: 'Validate URLs against an allowlist before making HTTP requests.',
},
{
pattern: /\brequest\s*\(\s*req\.(params|query|body)/,
label: 'request() called with user-controlled URL',
suggestion: 'Validate URLs against an allowlist before making HTTP requests.',
},
];

const pyPatterns: { pattern: RegExp; label: string; suggestion: string }[] = [
{
pattern: /requests\.(get|post|put|delete|patch)\s*\(\s*(request\.args|request\.json\(\)|request\.form|request\.values)/,
label: 'requests called with user-controlled URL',
suggestion: 'Validate URLs against an allowlist before making HTTP requests.',
},
{
pattern: /urllib\.request\.urlopen\s*\(\s*(request\.args|request\.json\(\)|request\.form|request\.values)/,
label: 'urllib.request.urlopen called with user-controlled URL',
suggestion: 'Validate URLs against an allowlist before making HTTP requests.',
},
];

for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (isComment(line)) continue;

for (const { pattern, label, suggestion } of jsPatterns) {
if (pattern.test(line)) {
findings.push({
ruleId: ssrf.id,
ruleName: ssrf.name,
severity: ssrf.severity,
category: ssrf.category,
filePath,
line: i + 1,
column: line.search(pattern) + 1,
snippet: snippet(lines, i),
message: `${label}. This may allow SSRF attacks against internal services.`,
suggestion,
});
break;
}
}

for (const { pattern, label, suggestion } of pyPatterns) {
if (pattern.test(line)) {
findings.push({
ruleId: ssrf.id,
ruleName: ssrf.name,
severity: ssrf.severity,
category: ssrf.category,
filePath,
line: i + 1,
column: line.search(pattern) + 1,
snippet: snippet(lines, i),
message: `${label}. This may allow SSRF attacks against internal services.`,
suggestion,
});
break;
}
}
}

return findings;
},
};

export const a10Rules: Rule[] = [ssrf];
2 changes: 2 additions & 0 deletions packages/cli/src/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { a03Rules } from './a03-injection.js';
import { a05Rules } from './a05-security-misconfiguration.js';
import { a07Rules } from './a07-auth-failures.js';
import { a09Rules } from './a09-logging-failures.js';
import { a10Rules } from './a10-ssrf.js';

/**
* All registered security rules organized by OWASP category.
Expand All @@ -16,6 +17,7 @@ export const allRules: Rule[] = [
...a05Rules,
...a07Rules,
...a09Rules,
...a10Rules,
];

/**
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/test/fixtures/a10-ssrf-clean.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Deliberately clean fixture for A10 — must produce ZERO SSRF findings.
// Used only by packages/cli/test/native-rules.test.mjs

fetch('https://api.example.com/data');
axios.get('https://api.example.com/data');
axios.post('https://api.example.com/data');
http.get('https://api.example.com/data');
https.get('https://api.example.com/data');
request('https://api.example.com/data');
13 changes: 13 additions & 0 deletions packages/cli/test/fixtures/a10-ssrf-vuln.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Fixture for OWASP A10:2021 — SSRF (Python)
# Triggers: A10-SSRF (high)
# Used only by packages/cli/test/native-rules.test.mjs

import requests
import urllib.request

# 1. requests with user input
requests.get(request.args.get('url'))
requests.post(request.form['url'])

# 2. urllib with user input
urllib.request.urlopen(request.json()['url'])
23 changes: 23 additions & 0 deletions packages/cli/test/fixtures/a10-ssrf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Fixture for OWASP A10:2021 — SSRF
// Triggers: A10-SSRF (high)
// Used only by packages/cli/test/native-rules.test.mjs

declare const req: { params: { url: string }; query: { endpoint: string }; body: { imageUrl: string; webhook: string } };
declare const axios: { get(url: string): Promise<unknown>; post(url: string): Promise<unknown> };
declare const http: { get(url: string): Promise<unknown> };
declare const https: { get(url: string): Promise<unknown> };
declare const request: (url: string) => Promise<unknown>;

// 1. fetch with user input
fetch(req.params.url);

// 2. axios with user input
axios.get(req.query.endpoint);
axios.post(req.body.imageUrl);

// 3. http/https.get with user input
http.get(req.params.url);
https.get(req.query.endpoint);

// 4. request library with user input
request(req.body.webhook);
24 changes: 24 additions & 0 deletions packages/cli/test/native-rules.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ const FIXTURES = [
category: 'A09:2021-Logging-Failures',
expectedRules: ['A09-SENSITIVE-LOG', 'A09-CONSOLE-SENSITIVE', 'A09-EMPTY-CATCH'],
},
{
file: 'a10-ssrf.ts',
category: 'A10:2021-SSRF',
expectedRules: ['A10-SSRF'],
},
];

function stageFixtures() {
Expand All @@ -67,6 +72,9 @@ function stageFixtures() {
copyFileSync(join(FIXTURES_DIR, f.file), join(srcDir, f.file));
}
copyFileSync(join(FIXTURES_DIR, 'clean.ts'), join(srcDir, 'clean.ts'));
// A10 additional fixtures
copyFileSync(join(FIXTURES_DIR, 'a10-ssrf-vuln.py'), join(srcDir, 'a10-ssrf-vuln.py'));
copyFileSync(join(FIXTURES_DIR, 'a10-ssrf-clean.ts'), join(srcDir, 'a10-ssrf-clean.ts'));
return { dir, srcDir };
}

Expand Down Expand Up @@ -151,6 +159,22 @@ test('clean.ts produces zero findings', () => {
);
});

// ─── A10 additional fixtures ───────────────────────────────────────────────
test('a10-ssrf-vuln.py produces SSRF findings', () => {
const findings = report.findings.filter((f) => (f.file || '').endsWith('a10-ssrf-vuln.py'));
assert.ok(findings.length > 0, 'Expected at least one finding in a10-ssrf-vuln.py');
assert.ok(findings.some((f) => f.id === 'A10-SSRF'), 'Expected A10-SSRF to fire in py fixture');
});

test('a10-ssrf-clean.ts produces zero findings', () => {
const findings = report.findings.filter((f) => (f.file || '').endsWith('a10-ssrf-clean.ts'));
assert.equal(
findings.length,
0,
`a10-ssrf-clean.ts must produce 0 findings; got ${findings.length}: ${findings.map((f) => `${f.id} (${f.severity})`).join(', ')}`,
);
});

// ─── determinism ───────────────────────────────────────────────────────────
test('two sequential scans produce identical fingerprint sets', () => {
const second = runScan(staged.dir);
Expand Down