sentinel-scanner 1.0.1-alpha.1 → 1.1.0-alpha.1
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/.cspell.json +5 -2
- package/CHANGELOG.md +4 -3
- package/build/index.js +226 -3091
- package/build/index.js.map +4 -4
- package/package.json +18 -6
- package/scripts/build.ts +1 -1
- package/src/__tests__/spider.test.ts +1 -0
- package/src/index.ts +79 -20
- package/src/lib/logger.ts +43 -0
- package/src/modules/index.ts +3 -0
- package/src/modules/spider/index.ts +175 -0
- package/tsconfig.json +8 -3
- package/src/__tests__/index.test.ts +0 -0
package/src/index.ts
CHANGED
@@ -1,27 +1,86 @@
|
|
1
1
|
#!/usr/bin/env node
|
2
2
|
|
3
|
-
import
|
4
|
-
|
5
|
-
import
|
3
|
+
import yargs from "yargs";
|
4
|
+
import { hideBin } from "yargs/helpers";
|
5
|
+
import { SpiderScanner } from "./modules";
|
6
6
|
|
7
|
-
|
8
|
-
const program = new Command();
|
7
|
+
const commandHandler = yargs(hideBin(process.argv));
|
9
8
|
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
9
|
+
/**
|
10
|
+
* Command to scan for XSS vulnerabilities
|
11
|
+
*
|
12
|
+
* @param {string} url - URL to scan
|
13
|
+
* @param {string} wordlist - Path to wordlist file
|
14
|
+
* @returns {void}
|
15
|
+
*
|
16
|
+
* @example
|
17
|
+
* npx sentinel-scanner xss --url https://example.com
|
18
|
+
*/
|
19
|
+
commandHandler.command(
|
20
|
+
"xss",
|
21
|
+
"Scan for XSS vulnerabilities",
|
22
|
+
{
|
23
|
+
url: {
|
24
|
+
describe: "URL to scan",
|
25
|
+
demandOption: true,
|
26
|
+
type: "string",
|
27
|
+
coerce: (value) => {
|
28
|
+
try {
|
29
|
+
new URL(value);
|
30
|
+
return value;
|
31
|
+
} catch (err) {
|
32
|
+
throw new Error("Invalid URL format");
|
33
|
+
}
|
34
|
+
},
|
35
|
+
},
|
36
|
+
wordlist: {
|
37
|
+
describe: "Path to wordlist file",
|
38
|
+
type: "string",
|
39
|
+
},
|
40
|
+
},
|
41
|
+
(argv) => {
|
42
|
+
console.log("Scanning for XSS vulnerabilities...");
|
43
|
+
console.log(`URL: ${argv.url}`);
|
44
|
+
console.log(`Wordlist: ${argv.wordlist || "Default"}`);
|
45
|
+
},
|
46
|
+
);
|
15
47
|
|
16
|
-
//
|
17
|
-
|
48
|
+
// Command to Spider a website
|
49
|
+
commandHandler.command(
|
50
|
+
"spider",
|
51
|
+
"Scan a website for vulnerabilities",
|
52
|
+
{
|
53
|
+
url: {
|
54
|
+
describe: "URL to scan",
|
55
|
+
demandOption: true,
|
56
|
+
type: "string",
|
57
|
+
coerce: (value) => {
|
58
|
+
try {
|
59
|
+
new URL(value);
|
60
|
+
return value;
|
61
|
+
} catch (err) {
|
62
|
+
throw new Error("Invalid URL format");
|
63
|
+
}
|
64
|
+
},
|
65
|
+
},
|
66
|
+
},
|
67
|
+
(argv) => {
|
68
|
+
const spider = new SpiderScanner(argv.url);
|
18
69
|
|
19
|
-
|
20
|
-
|
70
|
+
spider.crawl().then((output) => {
|
71
|
+
console.log(
|
72
|
+
JSON.stringify(
|
73
|
+
{
|
74
|
+
forms: output.forms,
|
75
|
+
links: output.links,
|
76
|
+
},
|
77
|
+
null,
|
78
|
+
2,
|
79
|
+
),
|
80
|
+
);
|
81
|
+
});
|
82
|
+
},
|
83
|
+
);
|
21
84
|
|
22
|
-
|
23
|
-
|
24
|
-
// If no arguments are provided, display help
|
25
|
-
if (Object.keys(options).length === 0) {
|
26
|
-
program.help();
|
27
|
-
}
|
85
|
+
// Parse arguments and handle commands
|
86
|
+
commandHandler.parse();
|
@@ -0,0 +1,43 @@
|
|
1
|
+
export default class Logger {
|
2
|
+
private moduleName: string;
|
3
|
+
private colors = {
|
4
|
+
error: "\x1b[31m",
|
5
|
+
info: "\x1b[32m",
|
6
|
+
warn: "\x1b[33m",
|
7
|
+
debug: "\x1b[35m",
|
8
|
+
reset: "\x1b[0m",
|
9
|
+
module: "\x1b[46m",
|
10
|
+
};
|
11
|
+
|
12
|
+
constructor(moduleName: string) {
|
13
|
+
this.moduleName = moduleName;
|
14
|
+
}
|
15
|
+
|
16
|
+
private formatMessage(
|
17
|
+
level: keyof typeof this.colors,
|
18
|
+
...message: string[]
|
19
|
+
): string {
|
20
|
+
const timestamp = new Date().toTimeString().split(" ")[0];
|
21
|
+
return `[${level}] ${this.colors[level]}${this.colors.reset}${this.colors[level]}[${timestamp}]${this.colors.reset} ${this.colors.module}[${this.moduleName}]${this.colors.reset} ${this.colors[level]}${message}${this.colors.reset}`;
|
22
|
+
}
|
23
|
+
|
24
|
+
public error(...message: string[]): void {
|
25
|
+
console.error(this.formatMessage("error", ...message));
|
26
|
+
}
|
27
|
+
|
28
|
+
public info(...message: string[]): void {
|
29
|
+
console.info(this.formatMessage("info", ...message));
|
30
|
+
}
|
31
|
+
|
32
|
+
public warn(...message: string[]): void {
|
33
|
+
console.warn(this.formatMessage("warn", ...message));
|
34
|
+
}
|
35
|
+
|
36
|
+
public log(...message: string[]): void {
|
37
|
+
console.log(this.formatMessage("info", ...message));
|
38
|
+
}
|
39
|
+
|
40
|
+
public debug(...message: string[]): void {
|
41
|
+
console.debug(this.formatMessage("debug", ...message));
|
42
|
+
}
|
43
|
+
}
|
@@ -0,0 +1,175 @@
|
|
1
|
+
import fetch from "isomorphic-fetch";
|
2
|
+
import jsdom from "jsdom";
|
3
|
+
import UserAgent from "user-agents";
|
4
|
+
import Logger from "../../lib/logger";
|
5
|
+
|
6
|
+
export type FormOutput = {
|
7
|
+
id: number;
|
8
|
+
url: string;
|
9
|
+
fields: Array<{ name: string; id: string; class: string; type: string }>;
|
10
|
+
};
|
11
|
+
|
12
|
+
export type CrawlOutput = {
|
13
|
+
links: string[];
|
14
|
+
forms: FormOutput[];
|
15
|
+
};
|
16
|
+
|
17
|
+
export default class SpiderScanner {
|
18
|
+
private header: Record<string, string> = {
|
19
|
+
"User-Agent": new UserAgent().toString(),
|
20
|
+
};
|
21
|
+
private url: URL;
|
22
|
+
private logger = new Logger("Spider");
|
23
|
+
|
24
|
+
constructor(url: string) {
|
25
|
+
try {
|
26
|
+
this.url = new URL(url);
|
27
|
+
this.logger.info(
|
28
|
+
`Initialized with URL: ${url} & User-Agent: ${this.header["User-Agent"]}`,
|
29
|
+
);
|
30
|
+
} catch (error) {
|
31
|
+
if (error instanceof TypeError) {
|
32
|
+
this.logger.error("Invalid URL");
|
33
|
+
throw new Error("Invalid URL");
|
34
|
+
}
|
35
|
+
this.logger.error(`Unexpected error in constructor: ${error}`);
|
36
|
+
throw error;
|
37
|
+
}
|
38
|
+
}
|
39
|
+
|
40
|
+
// Normalize domains (removes 'www.')
|
41
|
+
private normalizeDomain(domain: string): string {
|
42
|
+
return domain.startsWith("www.") ? domain.slice(4) : domain;
|
43
|
+
}
|
44
|
+
|
45
|
+
private convertRelativeUrlToAbsolute(url: string): string {
|
46
|
+
return new URL(url, this.url.toString()).toString();
|
47
|
+
}
|
48
|
+
|
49
|
+
private isInternalLink(url: string): boolean {
|
50
|
+
try {
|
51
|
+
const parsedUrl = new URL(url, this.url.href);
|
52
|
+
if (!["http:", "https:"].includes(parsedUrl.protocol)) {
|
53
|
+
return false;
|
54
|
+
}
|
55
|
+
const baseDomain = this.normalizeDomain(this.url.hostname);
|
56
|
+
const parsedDomain = this.normalizeDomain(parsedUrl.hostname);
|
57
|
+
return parsedDomain === baseDomain;
|
58
|
+
} catch (error) {
|
59
|
+
this.logger.warn(`Error parsing URL: ${url} - ${error}`);
|
60
|
+
return false;
|
61
|
+
}
|
62
|
+
}
|
63
|
+
|
64
|
+
private async fetchUrl(url: string): Promise<string | null> {
|
65
|
+
try {
|
66
|
+
this.logger.debug(`Fetching URL: ${url}`);
|
67
|
+
const response = await fetch(url, { headers: this.header });
|
68
|
+
if (!response.ok) {
|
69
|
+
this.logger.warn(`Failed to fetch URL (${response.status}): ${url}`);
|
70
|
+
return null;
|
71
|
+
}
|
72
|
+
this.logger.info(`Successfully fetched URL: ${url}`);
|
73
|
+
return await response.text();
|
74
|
+
} catch (error) {
|
75
|
+
this.logger.error(`Error fetching URL: ${url} - ${error}`);
|
76
|
+
return null;
|
77
|
+
}
|
78
|
+
}
|
79
|
+
|
80
|
+
private extractLinks(html: string): string[] {
|
81
|
+
const { JSDOM } = jsdom;
|
82
|
+
const dom = new JSDOM(html);
|
83
|
+
const links = Array.from(dom.window.document.querySelectorAll("a"));
|
84
|
+
const hrefs = links.map((link) => link.href);
|
85
|
+
const internalLinks = hrefs.filter((href) => this.isInternalLink(href));
|
86
|
+
this.logger.debug(
|
87
|
+
`Extracted ${internalLinks.length} internal links from HTML content`,
|
88
|
+
);
|
89
|
+
return internalLinks.map((link) => this.convertRelativeUrlToAbsolute(link));
|
90
|
+
}
|
91
|
+
|
92
|
+
private extractForms(html: string): FormOutput[] {
|
93
|
+
const { JSDOM } = jsdom;
|
94
|
+
const dom = new JSDOM(html);
|
95
|
+
const forms = Array.from(dom.window.document.querySelectorAll("form"));
|
96
|
+
this.logger.debug(`Extracted ${forms.length} forms from HTML content`);
|
97
|
+
|
98
|
+
return forms.map((form, index) => {
|
99
|
+
const fields = Array.from(form.querySelectorAll("input")).map(
|
100
|
+
(input) => ({
|
101
|
+
name: input.name,
|
102
|
+
id: input.id,
|
103
|
+
class: input.className,
|
104
|
+
type: input.type,
|
105
|
+
}),
|
106
|
+
);
|
107
|
+
|
108
|
+
return {
|
109
|
+
id: index,
|
110
|
+
url: this.url.href,
|
111
|
+
fields,
|
112
|
+
};
|
113
|
+
});
|
114
|
+
}
|
115
|
+
|
116
|
+
// Main function to scan the website with concurrency support and return both links and forms
|
117
|
+
public async crawl(depth = 250, concurrency = 5): Promise<CrawlOutput> {
|
118
|
+
const visited = new Set<string>();
|
119
|
+
const queue = new Set<string>([this.url.href]);
|
120
|
+
const resultLinks = new Set<string>();
|
121
|
+
const resultForms = new Set<FormOutput>();
|
122
|
+
|
123
|
+
const fetchAndExtract = async (currentUrl: string) => {
|
124
|
+
if (visited.has(currentUrl)) {
|
125
|
+
this.logger.debug(`Skipping already visited URL: ${currentUrl}`);
|
126
|
+
return;
|
127
|
+
}
|
128
|
+
visited.add(currentUrl);
|
129
|
+
this.logger.info(`Visiting URL: ${currentUrl}`);
|
130
|
+
|
131
|
+
const html = await this.fetchUrl(currentUrl);
|
132
|
+
if (!html) return;
|
133
|
+
|
134
|
+
// Extract links and forms
|
135
|
+
const links = this.extractLinks(html);
|
136
|
+
const forms = this.extractForms(html);
|
137
|
+
|
138
|
+
for (const form of forms) {
|
139
|
+
resultForms.add(form);
|
140
|
+
}
|
141
|
+
|
142
|
+
for (const link of links) {
|
143
|
+
if (!visited.has(link) && queue.size < depth) {
|
144
|
+
queue.add(link);
|
145
|
+
this.logger.debug(`Added to queue: ${link}`);
|
146
|
+
}
|
147
|
+
}
|
148
|
+
resultLinks.add(currentUrl);
|
149
|
+
};
|
150
|
+
|
151
|
+
const processBatch = async () => {
|
152
|
+
const batch = Array.from(queue).slice(0, concurrency);
|
153
|
+
for (const url of batch) {
|
154
|
+
queue.delete(url);
|
155
|
+
}
|
156
|
+
await Promise.allSettled(batch.map((url) => fetchAndExtract(url)));
|
157
|
+
};
|
158
|
+
|
159
|
+
this.logger.info(
|
160
|
+
`Starting crawl with depth: ${depth}, concurrency: ${concurrency}`,
|
161
|
+
);
|
162
|
+
while (queue.size > 0 && visited.size < depth) {
|
163
|
+
await processBatch();
|
164
|
+
}
|
165
|
+
|
166
|
+
this.logger.info(
|
167
|
+
`Crawling completed. Total pages visited: ${resultLinks.size}, Total forms found: ${resultForms.size}`,
|
168
|
+
);
|
169
|
+
|
170
|
+
return {
|
171
|
+
links: Array.from(resultLinks),
|
172
|
+
forms: Array.from(resultForms),
|
173
|
+
};
|
174
|
+
}
|
175
|
+
}
|
package/tsconfig.json
CHANGED
@@ -2,13 +2,12 @@
|
|
2
2
|
"include": ["./src/**/*.ts"],
|
3
3
|
"compilerOptions": {
|
4
4
|
"lib": ["es2023"],
|
5
|
-
"module": "
|
5
|
+
"module": "ESNext",
|
6
6
|
"target": "es2022",
|
7
|
-
"moduleResolution": "
|
7
|
+
"moduleResolution": "node",
|
8
8
|
|
9
9
|
"rootDir": "./src",
|
10
10
|
"outDir": "build",
|
11
|
-
"resolvePackageJsonImports": true,
|
12
11
|
|
13
12
|
"strict": true,
|
14
13
|
"noUncheckedIndexedAccess": true,
|
@@ -19,6 +18,12 @@
|
|
19
18
|
"declaration": true,
|
20
19
|
"resolveJsonModule": true,
|
21
20
|
"emitDeclarationOnly": true,
|
21
|
+
|
22
|
+
// These two options can help resolve ESM issues
|
23
|
+
"allowSyntheticDefaultImports": true,
|
24
|
+
"isolatedModules": true,
|
25
|
+
|
26
|
+
// Ensure TypeScript recognizes .ts file extensions in ESM
|
22
27
|
"allowImportingTsExtensions": true
|
23
28
|
}
|
24
29
|
}
|
File without changes
|