@havoc-security/scanner 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +4 -0
- package/.turbo/turbo-test.log +22 -0
- package/dist/analyzers/AuthorizationCoverageAnalyzer.d.ts +7 -0
- package/dist/analyzers/AuthorizationCoverageAnalyzer.d.ts.map +1 -0
- package/dist/analyzers/AuthorizationCoverageAnalyzer.js +100 -0
- package/dist/analyzers/AuthorizationCoverageAnalyzer.js.map +1 -0
- package/dist/analyzers/CredentialExposureAnalyzer.d.ts +11 -0
- package/dist/analyzers/CredentialExposureAnalyzer.d.ts.map +1 -0
- package/dist/analyzers/CredentialExposureAnalyzer.js +262 -0
- package/dist/analyzers/CredentialExposureAnalyzer.js.map +1 -0
- package/dist/analyzers/DependencyAuditAnalyzer.d.ts +28 -0
- package/dist/analyzers/DependencyAuditAnalyzer.d.ts.map +1 -0
- package/dist/analyzers/DependencyAuditAnalyzer.js +107 -0
- package/dist/analyzers/DependencyAuditAnalyzer.js.map +1 -0
- package/dist/analyzers/EncryptionAnalyzer.d.ts +7 -0
- package/dist/analyzers/EncryptionAnalyzer.d.ts.map +1 -0
- package/dist/analyzers/EncryptionAnalyzer.js +170 -0
- package/dist/analyzers/EncryptionAnalyzer.js.map +1 -0
- package/dist/analyzers/FileUploadAnalyzer.d.ts +8 -0
- package/dist/analyzers/FileUploadAnalyzer.d.ts.map +1 -0
- package/dist/analyzers/FileUploadAnalyzer.js +193 -0
- package/dist/analyzers/FileUploadAnalyzer.js.map +1 -0
- package/dist/analyzers/IdorAnalyzer.d.ts +7 -0
- package/dist/analyzers/IdorAnalyzer.d.ts.map +1 -0
- package/dist/analyzers/IdorAnalyzer.js +91 -0
- package/dist/analyzers/IdorAnalyzer.js.map +1 -0
- package/dist/analyzers/MassAssignmentAnalyzer.d.ts +7 -0
- package/dist/analyzers/MassAssignmentAnalyzer.d.ts.map +1 -0
- package/dist/analyzers/MassAssignmentAnalyzer.js +90 -0
- package/dist/analyzers/MassAssignmentAnalyzer.js.map +1 -0
- package/dist/analyzers/PrivilegeEscalationAnalyzer.d.ts +7 -0
- package/dist/analyzers/PrivilegeEscalationAnalyzer.d.ts.map +1 -0
- package/dist/analyzers/PrivilegeEscalationAnalyzer.js +217 -0
- package/dist/analyzers/PrivilegeEscalationAnalyzer.js.map +1 -0
- package/dist/analyzers/RateLimitAnalyzer.d.ts +7 -0
- package/dist/analyzers/RateLimitAnalyzer.d.ts.map +1 -0
- package/dist/analyzers/RateLimitAnalyzer.js +151 -0
- package/dist/analyzers/RateLimitAnalyzer.js.map +1 -0
- package/dist/analyzers/SessionSecurityAnalyzer.d.ts +10 -0
- package/dist/analyzers/SessionSecurityAnalyzer.d.ts.map +1 -0
- package/dist/analyzers/SessionSecurityAnalyzer.js +295 -0
- package/dist/analyzers/SessionSecurityAnalyzer.js.map +1 -0
- package/dist/analyzers/SqlInjectionAnalyzer.d.ts +7 -0
- package/dist/analyzers/SqlInjectionAnalyzer.d.ts.map +1 -0
- package/dist/analyzers/SqlInjectionAnalyzer.js +77 -0
- package/dist/analyzers/SqlInjectionAnalyzer.js.map +1 -0
- package/dist/analyzers/XssSurfaceAnalyzer.d.ts +7 -0
- package/dist/analyzers/XssSurfaceAnalyzer.d.ts.map +1 -0
- package/dist/analyzers/XssSurfaceAnalyzer.js +100 -0
- package/dist/analyzers/XssSurfaceAnalyzer.js.map +1 -0
- package/dist/analyzers/index.d.ts +13 -0
- package/dist/analyzers/index.d.ts.map +1 -0
- package/dist/analyzers/index.js +13 -0
- package/dist/analyzers/index.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +139 -0
- package/dist/index.js.map +1 -0
- package/dist/parsers/PhpParser.d.ts +56 -0
- package/dist/parsers/PhpParser.d.ts.map +1 -0
- package/dist/parsers/PhpParser.js +193 -0
- package/dist/parsers/PhpParser.js.map +1 -0
- package/dist/parsers/RouteParser.d.ts +87 -0
- package/dist/parsers/RouteParser.d.ts.map +1 -0
- package/dist/parsers/RouteParser.js +327 -0
- package/dist/parsers/RouteParser.js.map +1 -0
- package/dist/rules/index.d.ts +14 -0
- package/dist/rules/index.d.ts.map +1 -0
- package/dist/rules/index.js +9 -0
- package/dist/rules/index.js.map +1 -0
- package/dist/types/index.d.ts +137 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +13 -0
- package/dist/types/index.js.map +1 -0
- package/package.json +30 -0
- package/package.json.bak +27 -0
- package/src/analyzers/AuthorizationCoverageAnalyzer.ts +213 -0
- package/src/analyzers/CredentialExposureAnalyzer.ts +312 -0
- package/src/analyzers/DependencyAuditAnalyzer.ts +135 -0
- package/src/analyzers/EncryptionAnalyzer.ts +195 -0
- package/src/analyzers/FileUploadAnalyzer.ts +239 -0
- package/src/analyzers/IdorAnalyzer.ts +118 -0
- package/src/analyzers/InsecureDeserializationAnalyzer.ts +212 -0
- package/src/analyzers/MassAssignmentAnalyzer.ts +105 -0
- package/src/analyzers/OpenRedirectAnalyzer.ts +149 -0
- package/src/analyzers/PrivilegeEscalationAnalyzer.ts +258 -0
- package/src/analyzers/RateLimitAnalyzer.ts +195 -0
- package/src/analyzers/SecurityHeaderAnalyzer.ts +263 -0
- package/src/analyzers/SessionSecurityAnalyzer.ts +342 -0
- package/src/analyzers/SqlInjectionAnalyzer.ts +99 -0
- package/src/analyzers/XssSurfaceAnalyzer.ts +112 -0
- package/src/analyzers/exclusions.ts +87 -0
- package/src/analyzers/index.ts +15 -0
- package/src/index.ts +226 -0
- package/src/parsers/PhpParser.ts +259 -0
- package/src/parsers/RouteParser.ts +384 -0
- package/src/rules/index.ts +16 -0
- package/src/types/index.ts +164 -0
- package/tests/EncryptionAnalyzer.test.ts +137 -0
- package/tests/PrivilegeEscalationAnalyzer.test.ts +141 -0
- package/tests/RateLimitAnalyzer.test.ts +112 -0
- package/tests/analyzers.test.ts +678 -0
- package/tests/auth-coverage-route-aware.test.ts +294 -0
- package/tests/credential-exposure.test.ts +142 -0
- package/tests/file-upload.test.ts +141 -0
- package/tests/fixtures/app/Http/Controllers/AdminController.php +19 -0
- package/tests/fixtures/app/Http/Controllers/PostController.php +49 -0
- package/tests/fixtures/app/Http/Controllers/PublicController.php +17 -0
- package/tests/fixtures/app/Models/Comment.php +11 -0
- package/tests/fixtures/app/Models/OpenModel.php +11 -0
- package/tests/fixtures/app/Models/Post.php +14 -0
- package/tests/fixtures/app/Models/SafeModel.php +10 -0
- package/tests/fixtures/app/Models/User.php +15 -0
- package/tests/fixtures/blade/mail.blade.php +8 -0
- package/tests/fixtures/blade/safe.blade.php +12 -0
- package/tests/fixtures/blade/vulnerable.blade.php +12 -0
- package/tests/fixtures/controllers/AdminController.php +19 -0
- package/tests/fixtures/controllers/PostController.php +49 -0
- package/tests/fixtures/controllers/PublicController.php +17 -0
- package/tests/fixtures/deserialization/safe.php +32 -0
- package/tests/fixtures/deserialization/unsafe.php +60 -0
- package/tests/fixtures/models/Comment.php +11 -0
- package/tests/fixtures/models/OpenModel.php +11 -0
- package/tests/fixtures/models/Post.php +14 -0
- package/tests/fixtures/models/SafeModel.php +10 -0
- package/tests/fixtures/models/User.php +15 -0
- package/tests/fixtures/redirect/safe.php +38 -0
- package/tests/fixtures/redirect/unsafe.php +39 -0
- package/tests/fixtures/routes/api.php +9 -0
- package/tests/fixtures/routes/web.php +18 -0
- package/tests/fixtures/security-headers/app/Http/Middleware/SecurityHeaders.php +24 -0
- package/tests/fixtures/security-headers/app/Providers/AppServiceProvider.php +16 -0
- package/tests/fixtures/sql/safe_queries.php +7 -0
- package/tests/fixtures/sql/vulnerable_queries.php +7 -0
- package/tests/new-analyzers.test.ts +373 -0
- package/tests/route-parser.test.ts +257 -0
- package/tests/scanner.test.ts +82 -0
- package/tests/session-security.test.ts +161 -0
- package/tests/types.test.ts +29 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HAVOC Scanner — Core Type Definitions
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// ─── Severity ────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export enum Severity {
|
|
8
|
+
Critical = 'critical',
|
|
9
|
+
High = 'high',
|
|
10
|
+
Medium = 'medium',
|
|
11
|
+
Low = 'low',
|
|
12
|
+
Info = 'info',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// ─── Finding ─────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export interface Finding {
|
|
18
|
+
/** Unique identifier for this finding (e.g., "HAVOC-001") */
|
|
19
|
+
id: string;
|
|
20
|
+
/** Severity level */
|
|
21
|
+
severity: Severity;
|
|
22
|
+
/** Name of the analyzer that produced this finding */
|
|
23
|
+
analyzer: string;
|
|
24
|
+
/** Short, descriptive title */
|
|
25
|
+
title: string;
|
|
26
|
+
/** Full description of the vulnerability */
|
|
27
|
+
description: string;
|
|
28
|
+
/** Relative file path where the issue was found */
|
|
29
|
+
file: string;
|
|
30
|
+
/** Line number (1-indexed) */
|
|
31
|
+
line: number;
|
|
32
|
+
/** Actionable recommendation for the developer */
|
|
33
|
+
recommendation: string;
|
|
34
|
+
/** CWE identifier (e.g., "CWE-89" for SQL Injection) */
|
|
35
|
+
cwe?: string;
|
|
36
|
+
/** Optional code snippet for context */
|
|
37
|
+
snippet?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ─── Parsed File ─────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
export interface ParsedFile {
|
|
43
|
+
/** Relative path to the file */
|
|
44
|
+
path: string;
|
|
45
|
+
/** Raw file contents */
|
|
46
|
+
content: string;
|
|
47
|
+
/** AST representation (framework-specific) */
|
|
48
|
+
ast?: unknown;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ─── Analyzer ────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
export interface Analyzer {
|
|
54
|
+
/** Unique analyzer name (e.g., "authorization-coverage") */
|
|
55
|
+
name: string;
|
|
56
|
+
/** Human-readable description of what this analyzer checks */
|
|
57
|
+
description: string;
|
|
58
|
+
/**
|
|
59
|
+
* Analyze the given parsed files and return any findings.
|
|
60
|
+
* @param files - Parsed PHP files to analyze
|
|
61
|
+
* @param config - Scanner configuration
|
|
62
|
+
*/
|
|
63
|
+
analyze(files: ParsedFile[], config: HavocConfig): Promise<Finding[]>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ─── Coverage Stats ───────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
export interface CoverageStats {
|
|
69
|
+
/** Total routes/endpoints found */
|
|
70
|
+
totalRoutes: number;
|
|
71
|
+
/** Routes with authorization middleware */
|
|
72
|
+
coveredRoutes: number;
|
|
73
|
+
/** Coverage percentage (0–100) */
|
|
74
|
+
coveragePercent: number;
|
|
75
|
+
/** Models scanned for mass assignment */
|
|
76
|
+
totalModels: number;
|
|
77
|
+
/** Models with $fillable or $guarded properly set */
|
|
78
|
+
protectedModels: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─── Scan Result ─────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
export interface ScanResult {
|
|
84
|
+
/** All findings from all analyzers */
|
|
85
|
+
findings: Finding[];
|
|
86
|
+
/** Coverage statistics */
|
|
87
|
+
coverage: CoverageStats;
|
|
88
|
+
/** Metadata about the scan */
|
|
89
|
+
metadata: ScanMetadata;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface ScanMetadata {
|
|
93
|
+
/** When the scan was performed */
|
|
94
|
+
scannedAt: string;
|
|
95
|
+
/** Framework detected (e.g., "laravel", "symfony") */
|
|
96
|
+
framework: string;
|
|
97
|
+
/** Framework version if detected */
|
|
98
|
+
frameworkVersion?: string;
|
|
99
|
+
/** Total files scanned */
|
|
100
|
+
filesScanned: number;
|
|
101
|
+
/** Duration in milliseconds */
|
|
102
|
+
durationMs: number;
|
|
103
|
+
/** HAVOC scanner version */
|
|
104
|
+
scannerVersion: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─── Configuration ───────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
export interface HavocConfig {
|
|
110
|
+
/**
|
|
111
|
+
* Framework to use for analysis.
|
|
112
|
+
* @default "laravel"
|
|
113
|
+
*/
|
|
114
|
+
framework: 'laravel' | 'symfony' | 'auto';
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Severity threshold for failing CI.
|
|
118
|
+
* Findings at or above this level will fail the build.
|
|
119
|
+
* @default "high"
|
|
120
|
+
*/
|
|
121
|
+
failOn: Severity;
|
|
122
|
+
|
|
123
|
+
/** List of file/directory patterns to exclude */
|
|
124
|
+
exclude?: string[];
|
|
125
|
+
|
|
126
|
+
/** Analyzer-specific configuration */
|
|
127
|
+
analyzers?: AnalyzerConfig;
|
|
128
|
+
|
|
129
|
+
/** Baseline file path for suppressing known findings */
|
|
130
|
+
baseline?: string;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Output format.
|
|
134
|
+
* @default "text"
|
|
135
|
+
*/
|
|
136
|
+
output?: 'text' | 'json' | 'sarif' | 'github';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface AnalyzerConfig {
|
|
140
|
+
authorizationCoverage?: {
|
|
141
|
+
enabled: boolean;
|
|
142
|
+
/** Minimum acceptable coverage percent */
|
|
143
|
+
minCoverage?: number;
|
|
144
|
+
/** Route prefixes to exclude (e.g., ["api/public"]) */
|
|
145
|
+
excludePrefixes?: string[];
|
|
146
|
+
};
|
|
147
|
+
massAssignment?: {
|
|
148
|
+
enabled: boolean;
|
|
149
|
+
};
|
|
150
|
+
xssSurface?: {
|
|
151
|
+
enabled: boolean;
|
|
152
|
+
};
|
|
153
|
+
sqlInjection?: {
|
|
154
|
+
enabled: boolean;
|
|
155
|
+
};
|
|
156
|
+
dependencyAudit?: {
|
|
157
|
+
enabled: boolean;
|
|
158
|
+
/** Max allowed days since last security update */
|
|
159
|
+
maxAgeDays?: number;
|
|
160
|
+
};
|
|
161
|
+
idor?: {
|
|
162
|
+
enabled: boolean;
|
|
163
|
+
};
|
|
164
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { EncryptionAnalyzer } from '../src/analyzers/EncryptionAnalyzer.js';
|
|
3
|
+
import { DEFAULT_CONFIG } from '../src/index.js';
|
|
4
|
+
import { PhpParser } from '../src/parsers/PhpParser.js';
|
|
5
|
+
import { writeFile, unlink } from 'node:fs/promises';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import type { ParsedFile } from '../src/types/index.js';
|
|
9
|
+
import { Severity } from '../src/types/index.js';
|
|
10
|
+
|
|
11
|
+
const analyzer = new EncryptionAnalyzer();
|
|
12
|
+
const parser = new PhpParser();
|
|
13
|
+
|
|
14
|
+
async function parsedModel(phpContent: string, className = 'TestModel'): Promise<ParsedFile> {
|
|
15
|
+
const tmpPath = join(tmpdir(), `${className}_${Date.now()}.php`);
|
|
16
|
+
await writeFile(tmpPath, phpContent, 'utf-8');
|
|
17
|
+
try {
|
|
18
|
+
const parsed = await parser.parseFile(tmpPath, tmpdir());
|
|
19
|
+
return { ...parsed, path: `app/Models/${className}.php` };
|
|
20
|
+
} finally {
|
|
21
|
+
await unlink(tmpPath).catch(() => {});
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('EncryptionAnalyzer', () => {
|
|
26
|
+
it('has correct name', () => {
|
|
27
|
+
expect(analyzer.name).toBe('EncryptionAnalyzer');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('has a non-empty description', () => {
|
|
31
|
+
expect(analyzer.description.length).toBeGreaterThan(0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('returns empty array for empty file list', async () => {
|
|
35
|
+
const result = await analyzer.analyze([], DEFAULT_CONFIG);
|
|
36
|
+
expect(result).toHaveLength(0);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('flags ssn field without encrypted cast as HIGH severity', async () => {
|
|
40
|
+
const file = await parsedModel(`<?php
|
|
41
|
+
namespace App\\Models;
|
|
42
|
+
class Patient extends \\Illuminate\\Database\\Eloquent\\Model {
|
|
43
|
+
protected $fillable = ['name', 'ssn'];
|
|
44
|
+
}`);
|
|
45
|
+
const result = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
46
|
+
const finding = result.find((f) => f.id === 'HAVOC-ENC-001' && f.title.includes('ssn'));
|
|
47
|
+
expect(finding).toBeDefined();
|
|
48
|
+
expect(finding?.severity).toBe(Severity.High);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('flags api_key field without encrypted cast', async () => {
|
|
52
|
+
const file = await parsedModel(`<?php
|
|
53
|
+
namespace App\\Models;
|
|
54
|
+
class Integration extends \\Illuminate\\Database\\Eloquent\\Model {
|
|
55
|
+
protected $fillable = ['name', 'api_key'];
|
|
56
|
+
}`);
|
|
57
|
+
const result = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
58
|
+
expect(result.some((f) => f.title.includes('api_key'))).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('does NOT flag sensitive field that has encrypted cast', async () => {
|
|
62
|
+
const file = await parsedModel(`<?php
|
|
63
|
+
namespace App\\Models;
|
|
64
|
+
class Patient extends \\Illuminate\\Database\\Eloquent\\Model {
|
|
65
|
+
protected $fillable = ['name', 'ssn'];
|
|
66
|
+
protected $casts = ['ssn' => 'encrypted'];
|
|
67
|
+
}`);
|
|
68
|
+
const result = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
69
|
+
expect(result.filter((f) => f.id === 'HAVOC-ENC-001' && f.title.includes('ssn'))).toHaveLength(0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('flags sensitive field with non-encrypted cast as MEDIUM', async () => {
|
|
73
|
+
const file = await parsedModel(`<?php
|
|
74
|
+
namespace App\\Models;
|
|
75
|
+
class Payment extends \\Illuminate\\Database\\Eloquent\\Model {
|
|
76
|
+
protected $fillable = ['amount', 'credit_card'];
|
|
77
|
+
protected $casts = ['credit_card' => 'string'];
|
|
78
|
+
}`);
|
|
79
|
+
const result = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
80
|
+
const finding = result.find((f) => f.id === 'HAVOC-ENC-002');
|
|
81
|
+
expect(finding).toBeDefined();
|
|
82
|
+
expect(finding?.severity).toBe(Severity.Medium);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('does NOT flag non-sensitive fields', async () => {
|
|
86
|
+
const file = await parsedModel(`<?php
|
|
87
|
+
namespace App\\Models;
|
|
88
|
+
class Post extends \\Illuminate\\Database\\Eloquent\\Model {
|
|
89
|
+
protected $fillable = ['title', 'body', 'published_at'];
|
|
90
|
+
}`);
|
|
91
|
+
const result = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
92
|
+
expect(result.filter((f) => f.id === 'HAVOC-ENC-001')).toHaveLength(0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('ignores non-model files', async () => {
|
|
96
|
+
const file: ParsedFile = {
|
|
97
|
+
path: 'app/Http/Controllers/UserController.php',
|
|
98
|
+
content: "<?php class UserController { protected $fillable = ['ssn']; }",
|
|
99
|
+
ast: null,
|
|
100
|
+
};
|
|
101
|
+
const result = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
102
|
+
expect(result.filter((f) => f.id === 'HAVOC-ENC-001')).toHaveLength(0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('emits APP_KEY rotation info finding when config/app.php is present', async () => {
|
|
106
|
+
const appConfig: ParsedFile = {
|
|
107
|
+
path: 'config/app.php',
|
|
108
|
+
content: "<?php return ['key' => env('APP_KEY', '')];",
|
|
109
|
+
ast: null,
|
|
110
|
+
};
|
|
111
|
+
const result = await analyzer.analyze([appConfig], DEFAULT_CONFIG);
|
|
112
|
+
expect(result.some((f) => f.id === 'HAVOC-ENC-003')).toBe(true);
|
|
113
|
+
expect(result.find((f) => f.id === 'HAVOC-ENC-003')?.severity).toBe(Severity.Info);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('uses CWE-312 for unencrypted sensitive fields', async () => {
|
|
117
|
+
const file = await parsedModel(`<?php
|
|
118
|
+
namespace App\\Models;
|
|
119
|
+
class Employee extends \\Illuminate\\Database\\Eloquent\\Model {
|
|
120
|
+
protected $fillable = ['name', 'tax_id'];
|
|
121
|
+
}`);
|
|
122
|
+
const result = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
123
|
+
const finding = result.find((f) => f.title.includes('tax_id'));
|
|
124
|
+
expect(finding?.cwe).toBe('CWE-312');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('accepts encrypted:array and encrypted:object as valid encrypted casts', async () => {
|
|
128
|
+
const file = await parsedModel(`<?php
|
|
129
|
+
namespace App\\Models;
|
|
130
|
+
class Vault extends \\Illuminate\\Database\\Eloquent\\Model {
|
|
131
|
+
protected $fillable = ['bank_account', 'secret'];
|
|
132
|
+
protected $casts = ['bank_account' => 'encrypted:array', 'secret' => 'encrypted:object'];
|
|
133
|
+
}`);
|
|
134
|
+
const result = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
135
|
+
expect(result.filter((f) => f.id === 'HAVOC-ENC-001')).toHaveLength(0);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { PrivilegeEscalationAnalyzer } from '../src/analyzers/PrivilegeEscalationAnalyzer.js';
|
|
3
|
+
import { DEFAULT_CONFIG } from '../src/index.js';
|
|
4
|
+
import { PhpParser } from '../src/parsers/PhpParser.js';
|
|
5
|
+
import { writeFile, unlink } from 'node:fs/promises';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import type { ParsedFile } from '../src/types/index.js';
|
|
9
|
+
import { Severity } from '../src/types/index.js';
|
|
10
|
+
|
|
11
|
+
const analyzer = new PrivilegeEscalationAnalyzer();
|
|
12
|
+
const parser = new PhpParser();
|
|
13
|
+
|
|
14
|
+
async function parsedFile(phpContent: string, subPath: string): Promise<ParsedFile> {
|
|
15
|
+
const tmpPath = join(tmpdir(), `pe_test_${Date.now()}.php`);
|
|
16
|
+
await writeFile(tmpPath, phpContent, 'utf-8');
|
|
17
|
+
try {
|
|
18
|
+
const parsed = await parser.parseFile(tmpPath, tmpdir());
|
|
19
|
+
return { ...parsed, path: subPath };
|
|
20
|
+
} finally {
|
|
21
|
+
await unlink(tmpPath).catch(() => {});
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function routeFile(content: string): ParsedFile {
|
|
26
|
+
return { path: 'routes/web.php', content, ast: null };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('PrivilegeEscalationAnalyzer', () => {
|
|
30
|
+
it('has correct name', () => {
|
|
31
|
+
expect(analyzer.name).toBe('PrivilegeEscalationAnalyzer');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('has a non-empty description', () => {
|
|
35
|
+
expect(analyzer.description.length).toBeGreaterThan(0);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('returns empty array for empty file list', async () => {
|
|
39
|
+
const result = await analyzer.analyze([], DEFAULT_CONFIG);
|
|
40
|
+
expect(result).toHaveLength(0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('flags is_admin in $fillable as CRITICAL', async () => {
|
|
44
|
+
const file = await parsedFile(`<?php
|
|
45
|
+
namespace App\\Models;
|
|
46
|
+
class User extends \\Illuminate\\Database\\Eloquent\\Model {
|
|
47
|
+
protected $fillable = ['name', 'email', 'is_admin'];
|
|
48
|
+
}`, 'app/Models/User.php');
|
|
49
|
+
const result = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
50
|
+
const finding = result.find((f) => f.id === 'HAVOC-PE-001' && f.title.includes('is_admin'));
|
|
51
|
+
expect(finding).toBeDefined();
|
|
52
|
+
expect(finding?.severity).toBe(Severity.Critical);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('flags role in $fillable as CRITICAL', async () => {
|
|
56
|
+
const file = await parsedFile(`<?php
|
|
57
|
+
namespace App\\Models;
|
|
58
|
+
class User extends \\Illuminate\\Database\\Eloquent\\Model {
|
|
59
|
+
protected $fillable = ['name', 'email', 'role'];
|
|
60
|
+
}`, 'app/Models/User.php');
|
|
61
|
+
const result = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
62
|
+
expect(result.some((f) => f.id === 'HAVOC-PE-001' && f.title.includes('role'))).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('flags is_superadmin in $fillable as CRITICAL', async () => {
|
|
66
|
+
const file = await parsedFile(`<?php
|
|
67
|
+
namespace App\\Models;
|
|
68
|
+
class User extends \\Illuminate\\Database\\Eloquent\\Model {
|
|
69
|
+
protected $fillable = ['name', 'is_superadmin'];
|
|
70
|
+
}`, 'app/Models/User.php');
|
|
71
|
+
const result = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
72
|
+
expect(result.some((f) => f.severity === Severity.Critical)).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('does NOT flag non-privilege fields in $fillable', async () => {
|
|
76
|
+
const file = await parsedFile(`<?php
|
|
77
|
+
namespace App\\Models;
|
|
78
|
+
class Post extends \\Illuminate\\Database\\Eloquent\\Model {
|
|
79
|
+
protected $fillable = ['title', 'body', 'user_id'];
|
|
80
|
+
}`, 'app/Models/Post.php');
|
|
81
|
+
const result = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
82
|
+
expect(result.filter((f) => f.id === 'HAVOC-PE-001')).toHaveLength(0);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('flags assignRole() without authorization in controller as HIGH', async () => {
|
|
86
|
+
const file = await parsedFile(`<?php
|
|
87
|
+
namespace App\\Http\\Controllers;
|
|
88
|
+
class AdminController extends Controller {
|
|
89
|
+
public function assignRole(Request $request, User $user) {
|
|
90
|
+
$user->assignRole($request->role);
|
|
91
|
+
return response()->json(['status' => 'ok']);
|
|
92
|
+
}
|
|
93
|
+
}`, 'app/Http/Controllers/AdminController.php');
|
|
94
|
+
const result = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
95
|
+
const finding = result.find((f) => f.id === 'HAVOC-PE-002');
|
|
96
|
+
expect(finding).toBeDefined();
|
|
97
|
+
expect(finding?.severity).toBe(Severity.High);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('does NOT flag assignRole() when authorization is present', async () => {
|
|
101
|
+
const file = await parsedFile(`<?php
|
|
102
|
+
namespace App\\Http\\Controllers;
|
|
103
|
+
class AdminController extends Controller {
|
|
104
|
+
public function assignRole(Request $request, User $user) {
|
|
105
|
+
$this->authorize('manage-roles');
|
|
106
|
+
$user->assignRole($request->role);
|
|
107
|
+
}
|
|
108
|
+
}`, 'app/Http/Controllers/AdminController.php');
|
|
109
|
+
const result = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
110
|
+
expect(result.filter((f) => f.id === 'HAVOC-PE-002' && result[0]?.title.includes('without authorization'))).toHaveLength(0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('flags admin route without admin middleware as MEDIUM', async () => {
|
|
114
|
+
const content = "Route::get('/admin/users', [AdminController::class, 'index']);";
|
|
115
|
+
const file = routeFile(content);
|
|
116
|
+
const result = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
117
|
+
const finding = result.find((f) => f.id === 'HAVOC-PE-003');
|
|
118
|
+
expect(finding).toBeDefined();
|
|
119
|
+
expect(finding?.severity).toBe(Severity.Medium);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('does NOT flag admin route that has role:admin middleware', async () => {
|
|
123
|
+
const content = `
|
|
124
|
+
Route::middleware(['auth', 'role:admin'])->group(function () {
|
|
125
|
+
Route::get('/admin/users', [AdminController::class, 'index']);
|
|
126
|
+
});`;
|
|
127
|
+
const file = routeFile(content);
|
|
128
|
+
const result = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
129
|
+
expect(result.filter((f) => f.id === 'HAVOC-PE-003')).toHaveLength(0);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('uses CWE-269 for all privilege escalation findings', async () => {
|
|
133
|
+
const file = await parsedFile(`<?php
|
|
134
|
+
namespace App\\Models;
|
|
135
|
+
class User extends \\Illuminate\\Database\\Eloquent\\Model {
|
|
136
|
+
protected $fillable = ['name', 'is_admin'];
|
|
137
|
+
}`, 'app/Models/User.php');
|
|
138
|
+
const result = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
139
|
+
expect(result[0]?.cwe).toBe('CWE-269');
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { RateLimitAnalyzer } from '../src/analyzers/RateLimitAnalyzer.js';
|
|
3
|
+
import { DEFAULT_CONFIG } from '../src/index.js';
|
|
4
|
+
import type { ParsedFile } from '../src/types/index.js';
|
|
5
|
+
import { Severity } from '../src/types/index.js';
|
|
6
|
+
|
|
7
|
+
function routeFile(content: string, name = 'routes/api.php'): ParsedFile {
|
|
8
|
+
return { path: name, content, ast: null };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const analyzer = new RateLimitAnalyzer();
|
|
12
|
+
|
|
13
|
+
describe('RateLimitAnalyzer', () => {
|
|
14
|
+
it('has correct name', () => {
|
|
15
|
+
expect(analyzer.name).toBe('RateLimitAnalyzer');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('has a non-empty description', () => {
|
|
19
|
+
expect(analyzer.description.length).toBeGreaterThan(0);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns empty array for empty file list', async () => {
|
|
23
|
+
const result = await analyzer.analyze([], DEFAULT_CONFIG);
|
|
24
|
+
expect(result).toHaveLength(0);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('ignores non-route files', async () => {
|
|
28
|
+
const file: ParsedFile = {
|
|
29
|
+
path: 'app/Http/Controllers/AuthController.php',
|
|
30
|
+
content: "Route::post('/login', [AuthController::class, 'login']);",
|
|
31
|
+
ast: null,
|
|
32
|
+
};
|
|
33
|
+
const result = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
34
|
+
expect(result).toHaveLength(0);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('flags login route without throttle as HIGH severity', async () => {
|
|
38
|
+
const file = routeFile("Route::post('/login', [AuthController::class, 'login']);");
|
|
39
|
+
const result = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
40
|
+
expect(result.length).toBeGreaterThan(0);
|
|
41
|
+
expect(result[0].severity).toBe(Severity.High);
|
|
42
|
+
expect(result[0].id).toBe('HAVOC-RL-001');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('flags password reset route without throttle', async () => {
|
|
46
|
+
const file = routeFile("Route::post('/password/reset', [PasswordController::class, 'reset']);");
|
|
47
|
+
const result = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
48
|
+
const authFindings = result.filter((f) => f.id === 'HAVOC-RL-001');
|
|
49
|
+
expect(authFindings.length).toBeGreaterThan(0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('does NOT flag login route when throttle middleware is present', async () => {
|
|
53
|
+
const file = routeFile(
|
|
54
|
+
"Route::post('/login', [AuthController::class, 'login'])->middleware('throttle:5,1');",
|
|
55
|
+
);
|
|
56
|
+
const result = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
57
|
+
expect(result.filter((f) => f.id === 'HAVOC-RL-001')).toHaveLength(0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('does NOT flag routes inside a throttled group', async () => {
|
|
61
|
+
const content = `
|
|
62
|
+
Route::middleware(['throttle:10,1'])->group(function () {
|
|
63
|
+
Route::post('/login', [AuthController::class, 'login']);
|
|
64
|
+
});
|
|
65
|
+
`;
|
|
66
|
+
const file = routeFile(content);
|
|
67
|
+
const result = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
68
|
+
expect(result.filter((f) => f.id === 'HAVOC-RL-001')).toHaveLength(0);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('flags API endpoints without throttle as MEDIUM severity', async () => {
|
|
72
|
+
const content = "Route::get('/users', [UserController::class, 'index']);";
|
|
73
|
+
const file = routeFile(content, 'routes/api.php');
|
|
74
|
+
const result = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
75
|
+
const apiFindings = result.filter((f) => f.id === 'HAVOC-RL-002');
|
|
76
|
+
expect(apiFindings.length).toBeGreaterThan(0);
|
|
77
|
+
expect(apiFindings[0].severity).toBe(Severity.Medium);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('includes CWE-307 on auth findings and CWE-770 on API findings', async () => {
|
|
81
|
+
const content = `
|
|
82
|
+
Route::post('/login', [AuthController::class, 'login']);
|
|
83
|
+
Route::get('/items', [ItemController::class, 'index']);
|
|
84
|
+
`;
|
|
85
|
+
const file = routeFile(content, 'routes/api.php');
|
|
86
|
+
const result = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
87
|
+
const authFinding = result.find((f) => f.id === 'HAVOC-RL-001');
|
|
88
|
+
const apiFinding = result.find((f) => f.id === 'HAVOC-RL-002');
|
|
89
|
+
expect(authFinding?.cwe).toBe('CWE-307');
|
|
90
|
+
expect(apiFinding?.cwe).toBe('CWE-770');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('does not flag web.php non-auth non-api routes', async () => {
|
|
94
|
+
const content = "Route::get('/home', [HomeController::class, 'index']);";
|
|
95
|
+
const file = routeFile(content, 'routes/web.php');
|
|
96
|
+
const result = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
97
|
+
// home route is not auth-sensitive and web.php is not api file
|
|
98
|
+
expect(result.filter((f) => f.id === 'HAVOC-RL-001')).toHaveLength(0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('flags register endpoint without throttle', async () => {
|
|
102
|
+
const file = routeFile("Route::post('/register', [RegisterController::class, 'store']);");
|
|
103
|
+
const result = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
104
|
+
expect(result.some((f) => f.id === 'HAVOC-RL-001')).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('flags webhook endpoint without throttle', async () => {
|
|
108
|
+
const file = routeFile("Route::post('/webhook/stripe', [WebhookController::class, 'handle']);");
|
|
109
|
+
const result = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
110
|
+
expect(result.some((f) => f.id === 'HAVOC-RL-001')).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
});
|