nsauditor-ai 0.1.84 β 0.1.86
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/README.md +6 -1
- package/package.json +2 -4
- package/plugins/webapp_detector.mjs +12 -12
- package/plugins/wsd_scanner.mjs +1 -2
- package/utils/finding_schema.mjs +1 -2
- package/utils/tech_fingerprint.mjs +249 -0
package/README.md
CHANGED
|
@@ -17,8 +17,13 @@ NSAuditor AI is the open-source core of a privacy-first security intelligence pl
|
|
|
17
17
|
|
|
18
18
|
## What's New
|
|
19
19
|
|
|
20
|
-
**Latest: CE 0.1.
|
|
20
|
+
**Latest: CE 0.1.86 + Enterprise 0.15.5** (May 2026)
|
|
21
21
|
|
|
22
|
+
- π§Ή **Dependency-hygiene / institutional-trust patch** (CE 0.1.86 β paired EE 0.15.5) β removes deprecation warnings + advisories from `npm install`. The abandoned `simple-wappalyzer` (β deprecated, now-commercial `wappalyzer-core`) is replaced by an in-house **zero-dependency** web tech fingerprinter (`utils/tech_fingerprint.mjs` β header/HTML/script-src/cookie/meta signatures; same `{name,categories,confidence,version}` output). `@anthropic-ai/sdk` bumped `^0.82`β`^0.100` (exits the GHSA-p7fg-763f-g4gf range; the Filesystem Memory Tool is never used). The direct `uuid` dependency is dropped in favor of native `crypto.randomUUID()`. NEW `SECURITY.md` documents remaining transitive notices. No feature/behavior change.
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
- πͺ£ **Non-current-version ACL sampling + public WRITE-vs-READ differentiation** (EE 0.15.4 β paired) β closes the two residuals the 0.15.3 spec carried as deferred. Plugin 1020 gains NEW step 2c-v: on versioning-Enabled/Suspended buckets it samples **non-current** object versions (`ListObjectVersions` first-page, cap-bounded β per-version `GetObjectAcl({VersionId})`, delete-markers skipped) β catching a public ACL that survives on an **overwritten** version still downloadable via `?versionId=` after the current version is made private (CRITICAL via the existing `"publicly accessible"` anchor; PAB `IgnorePublicAcls` β LOW; skipped on `BucketOwnerEnforced`). NEW sibling helper `extractPublicWriteGroups` flags public WRITE/WRITE_ACP/FULL_CONTROL grants (anyone-can-overwrite) distinctly from READ-only, as an enrichment line on the already-CRITICAL finding at bucket/object/version ACL sites. `ListObjectVersions AccessDenied` (distinct `s3:ListBucketVersions` action) + a `GetBucketVersioning AccessDenied` review-fold both degrade to routed LOW evidence-gaps via the existing `"S3 object-ACL evidence-gap"` anchor β never a silent PASS. **Enterprise plugin count UNCHANGED (28); all six coverage matrices UNCHANGED; ZERO framework-JSON edits.** TDD-first, +27 tests; EE regression 6628/6628 GREEN. _(Staged on `main`; awaiting live smoke + trio publish.)_
|
|
22
27
|
- πͺ£ **Object-level ACL enumeration + BucketOwnerEnforced short-circuit** (EE 0.15.3 β paired) β closes the 4th and final S3 public-exposure vector (object-level ACLs) documented as a residual in the 0.15.2 closure. Plugin 1020 gains NEW step 2c sampled `GetObjectAcl` enumeration over first-page objects (default cap 10; per-object throttle default 50ms) + NEW step 2a `GetBucketOwnershipControls` upstream short-circuit that skips both 2b (bucket-ACL) and 2c (object-ACL) on `BucketOwnerEnforced` buckets (the default since April 2023; saves 11+ API calls per BOE bucket on modern estates AND closes a false-positive class where BOE buckets with legacy stored public ACL grants previously emitted CRITICAL β they now emit informational because S3 structurally ignores ACL grants under BOE). **INTENTIONAL MATRIX DELTA from 0.15.2** on BOE buckets with pre-BOE legacy grants: CRITICAL β informational. NEW shared `extractPublicGroups` helper used by BOTH step 2b (refactored byte-identical) AND step 2c. 4 LOW evidence-gap emissions via NEW `"S3 object-ACL evidence-gap"` substring anchor on SOC 2 CC7.1 + HIPAA Β§164.312(b) (substrate-depth on already-covered controls; matrices unchanged). Live AWS smoke all 4 spot-checks PASS (BOE detection; E1 CRITICAL en-dash bytes preserved; cap clamping; objectRateMs throttling). Enterprise plugin count unchanged (28); all six coverage matrices unchanged.
|
|
23
28
|
- π― **Audit-accuracy calibration & CloudTrail hardening** (EE 0.15.2 β paired) β four real-production-account-driven folds: **(1)** plugin 1020 (S3) effective-public-exposure calibration β missing/partial Public Access Block downgraded CRITICALβMEDIUM (guardrail gap, not current exposure) + NEW `GetBucketAcl` check completing the ACLΓpolicyΓPAB join (public AllUsers/AuthenticatedUsers grant β CRITICAL unless neutralized by PAB `IgnorePublicAcls`); **(2)** plugin 1040 (CloudTrail) KMS-CMK calibration β trail-level "KmsKeyId not set" downgraded MEDIUMβLOW when the destination bucket has default SSE-KMS; **(3)** plugin 1040 (CloudTrail) multi-region timeout hardening β an `AbortController` tied to the soft-budget deadline lets a hung disabled-region abort so the plugin finalizes PARTIAL evidence; **(4)** plugin 1221 (Azure NSG) +10 restricted UDP ports + plugin 1222 (Azure Key Vault) F-2 custom-role resolution + F-7.2 HSM dim. Enterprise plugin count unchanged (28); all six coverage matrices unchanged.
|
|
24
29
|
- π§ **Azure Key Vault Deep auditor hotfix** (EE 0.15.1 β plugin 1222) β two defects surfaced by the 0.15.0 published-build smoke. **H-1**: the diagnostic-logging dim treated `@azure/arm-monitor`'s `diagnosticSettings.list()` as a paged async-iterator when it actually returns a `{value:[]}` collection object β the dim always degraded to a non-functional evidence-gap; now correctly `await`-ed and read via `.value` (confirmed against live Azure; the unit-test mock was the mock-vs-real-SDK mismatch that masked it). **H-2**: the privileged-access dim flagged inherited subscription/management-group-scope Owner/Contributor as HIGH on every RBAC vault β re-tuned so inherited Owner/User-Access-Admin β MEDIUM, inherited Contributor β LOW, with HIGH reserved for vault-scoped control-plane god roles + Key Vault Administrator. Additive bug-fix only; enterprise plugin count unchanged (28); all six coverage matrices unchanged.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nsauditor-ai",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.86",
|
|
4
4
|
"description": "Modular AI-assisted network security audit platform β Community Edition",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"plugin_manager.mjs"
|
|
28
28
|
],
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@anthropic-ai/sdk": "^0.
|
|
30
|
+
"@anthropic-ai/sdk": "^0.100.0",
|
|
31
31
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
32
32
|
"dnssd": "^0.4.1",
|
|
33
33
|
"dotenv": "^17.2.1",
|
|
@@ -36,10 +36,8 @@
|
|
|
36
36
|
"node-upnp-utils": "^1.0.3",
|
|
37
37
|
"openai": "^4.104.0",
|
|
38
38
|
"oui-data": "^1.1.427",
|
|
39
|
-
"simple-wappalyzer": "^1.1.75",
|
|
40
39
|
"snmp-native": "^1.2.0",
|
|
41
40
|
"jose": "^6.2.0",
|
|
42
|
-
"uuid": "^13.0.0",
|
|
43
41
|
"xml2js": "^0.6.2"
|
|
44
42
|
},
|
|
45
43
|
"optionalDependencies": {
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
// plugins/webapp_detector.mjs
|
|
2
2
|
// New plugin: Webapp Detector
|
|
3
|
-
// Uses
|
|
3
|
+
// Uses the in-house zero-dep fingerprinter (utils/tech_fingerprint.mjs) to identify
|
|
4
|
+
// web applications present on a host.
|
|
4
5
|
// Tries HTTPS first (port 443), then HTTP (port 80), and can also try custom ports via opts.ports.
|
|
5
6
|
// NOTE: Unlike http_probe, undici/fetch cannot ignore TLS easily per-request, so self-signed
|
|
6
7
|
// HTTPS will usually fail and the plugin will fall back to HTTP.
|
|
7
8
|
//
|
|
8
|
-
// Add to package.json (dependencies):
|
|
9
|
-
// "simple-wappalyzer": "^1.14.0" // or latest
|
|
10
|
-
//
|
|
11
9
|
// Example use (plugin manager):
|
|
12
10
|
// webappDetector.run("192.168.1.1")
|
|
13
11
|
//
|
|
@@ -29,7 +27,7 @@
|
|
|
29
27
|
// apps: [ { name, categories, confidence, version?, slug, ... }, ... ]
|
|
30
28
|
// }
|
|
31
29
|
|
|
32
|
-
import
|
|
30
|
+
import { fingerprint } from '../utils/tech_fingerprint.mjs';
|
|
33
31
|
|
|
34
32
|
const DEBUG =
|
|
35
33
|
String(process.env.DEBUG_MODE || '').toLowerCase() === '1' ||
|
|
@@ -107,18 +105,18 @@ async function tryDetectAt(url) {
|
|
|
107
105
|
}
|
|
108
106
|
}
|
|
109
107
|
|
|
110
|
-
/** Run
|
|
108
|
+
/** Run in-house fingerprinter on provided HTML/headers. */
|
|
111
109
|
async function detectFromHtml(url, html, statusCode, headers) {
|
|
112
110
|
try {
|
|
113
|
-
const result =
|
|
111
|
+
const result = fingerprint({ url, html, statusCode, headers });
|
|
114
112
|
if (Array.isArray(result) && result.length) {
|
|
115
|
-
log('
|
|
113
|
+
log('fingerprint apps=', result.map(a => a.name).join(', '));
|
|
116
114
|
} else {
|
|
117
|
-
log('
|
|
115
|
+
log('fingerprint apps=β
');
|
|
118
116
|
}
|
|
119
117
|
return result || [];
|
|
120
118
|
} catch (e) {
|
|
121
|
-
log('
|
|
119
|
+
log('fingerprint error:', e?.message || e);
|
|
122
120
|
return [];
|
|
123
121
|
}
|
|
124
122
|
}
|
|
@@ -137,7 +135,7 @@ function summarizeApps(apps) {
|
|
|
137
135
|
export default {
|
|
138
136
|
id: '010',
|
|
139
137
|
name: 'Webapp Detector',
|
|
140
|
-
description: 'Identifies web applications and frameworks using
|
|
138
|
+
description: 'Identifies web applications and frameworks using the in-house fingerprinter (tries HTTPS then HTTP).',
|
|
141
139
|
priority: 55, // run near HTTP probe
|
|
142
140
|
requirements: { host: 'up', tcp_open: [80, 443] }, // heuristic gate; still attempts both
|
|
143
141
|
protocols: ['tcp'],
|
|
@@ -156,7 +154,7 @@ export default {
|
|
|
156
154
|
os: null,
|
|
157
155
|
type: 'webapp',
|
|
158
156
|
data: [],
|
|
159
|
-
apps: [], // raw
|
|
157
|
+
apps: [], // raw fingerprinter results
|
|
160
158
|
};
|
|
161
159
|
|
|
162
160
|
let target = normalizeTarget(host);
|
|
@@ -244,3 +242,5 @@ export async function conclude({ host, result }) {
|
|
|
244
242
|
authoritative: false,
|
|
245
243
|
}));
|
|
246
244
|
}
|
|
245
|
+
|
|
246
|
+
export { detectFromHtml, summarizeApps };
|
package/plugins/wsd_scanner.mjs
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
|
|
6
6
|
import dgram from 'dgram';
|
|
7
7
|
import { parseString } from 'xml2js';
|
|
8
|
-
import { v4 as uuidv4 } from 'uuid';
|
|
9
8
|
import { isPrivateLike } from '../utils/net_validation.mjs';
|
|
10
9
|
|
|
11
10
|
const DEBUG = /^(1|true|yes|on)$/i.test(String(process.env.DEBUG_MODE || process.env.WSD_DEBUG || ""));
|
|
@@ -28,7 +27,7 @@ function discoverWsDiscoveryDevices(targetHost, timeout = 5000) {
|
|
|
28
27
|
const client = dgram.createSocket('udp4');
|
|
29
28
|
const devices = [];
|
|
30
29
|
const knownAddresses = new Set();
|
|
31
|
-
const probeMessageId = `urn:uuid:${
|
|
30
|
+
const probeMessageId = `urn:uuid:${crypto.randomUUID()}`;
|
|
32
31
|
const includeNonMatched = /^(1|true|yes|on)$/i.test(String(process.env.WSD_INCLUDE_NON_MATCHED || ""));
|
|
33
32
|
|
|
34
33
|
// The multicast address and port for WS-Discovery
|
package/utils/finding_schema.mjs
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
// utils/finding_schema.mjs
|
|
2
|
-
import { v4 as uuidv4 } from 'uuid';
|
|
3
2
|
|
|
4
3
|
export const FINDING_CATEGORIES = ['AUTH', 'CRYPTO', 'CONFIG', 'SERVICE', 'EXPOSURE', 'CVE'];
|
|
5
4
|
export const FINDING_STATUSES = ['UNVERIFIED', 'VERIFIED', 'POTENTIAL', 'FALSE_POSITIVE'];
|
|
@@ -62,5 +61,5 @@ export function validateFinding(f) {
|
|
|
62
61
|
* UUID-based β no counter to reset, no collision risk across restarts.
|
|
63
62
|
*/
|
|
64
63
|
export function generateFindingId() {
|
|
65
|
-
return `F-${
|
|
64
|
+
return `F-${crypto.randomUUID()}`;
|
|
66
65
|
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tech_fingerprint.mjs β zero-dependency web-technology fingerprinter.
|
|
3
|
+
* Replaces the abandoned `simple-wappalyzer` / deprecated `wappalyzer-core`.
|
|
4
|
+
* Returns wappalyzer-shaped results: [{ name, categories, confidence, version }].
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Signature table
|
|
9
|
+
// Each entry defines detection surfaces (OR semantics β any one match detects).
|
|
10
|
+
// Surfaces:
|
|
11
|
+
// header β [headerName, regex] tested against that header's value
|
|
12
|
+
// altHeader β [headerName, regex] secondary header (also OR'd in)
|
|
13
|
+
// meta β [metaName, regex] tested against <meta name=β¦ content=β¦>
|
|
14
|
+
// script β [regex, β¦] tested against each <script src="β¦"> URL
|
|
15
|
+
// cookie β [regex, β¦] tested against Set-Cookie header value
|
|
16
|
+
// versionFrom β { surface, key?, regex } β capture group 1 becomes version
|
|
17
|
+
// implies β [name, β¦] β inject those techs at confidence 50
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
const SIGS = [
|
|
20
|
+
{
|
|
21
|
+
name: 'Nginx',
|
|
22
|
+
categories: ['Web servers'],
|
|
23
|
+
header: ['server', /nginx/i],
|
|
24
|
+
versionFrom: { surface: 'header', key: 'server', regex: /nginx\/([\d.]+)/i },
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: 'Apache',
|
|
28
|
+
categories: ['Web servers'],
|
|
29
|
+
header: ['server', /apache/i],
|
|
30
|
+
versionFrom: { surface: 'header', key: 'server', regex: /apache\/([\d.]+)/i },
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: 'IIS',
|
|
34
|
+
categories: ['Web servers'],
|
|
35
|
+
header: ['server', /(?:microsoft-)?iis/i],
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: 'PHP',
|
|
39
|
+
categories: ['Programming languages'],
|
|
40
|
+
header: ['x-powered-by', /php/i],
|
|
41
|
+
versionFrom: { surface: 'header', key: 'x-powered-by', regex: /php\/([\d.]+)/i },
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: 'ASP.NET',
|
|
45
|
+
categories: ['Web frameworks'],
|
|
46
|
+
header: ['x-powered-by', /asp\.net/i],
|
|
47
|
+
altHeader: ['x-aspnet-version', /.+/],
|
|
48
|
+
versionFrom: { surface: 'header', key: 'x-aspnet-version', regex: /([\d.]+)/ },
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: 'Express',
|
|
52
|
+
categories: ['Web frameworks'],
|
|
53
|
+
header: ['x-powered-by', /express/i],
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: 'WordPress',
|
|
57
|
+
categories: ['CMS'],
|
|
58
|
+
meta: ['generator', /wordpress/i],
|
|
59
|
+
script: [/\/wp-(?:content|includes)\//i],
|
|
60
|
+
versionFrom: { surface: 'meta', key: 'generator', regex: /wordpress\s+([\d.]+)/i },
|
|
61
|
+
implies: ['PHP'],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: 'Drupal',
|
|
65
|
+
categories: ['CMS'],
|
|
66
|
+
meta: ['generator', /drupal/i],
|
|
67
|
+
altHeader: ['x-generator', /drupal/i],
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: 'Joomla',
|
|
71
|
+
categories: ['CMS'],
|
|
72
|
+
meta: ['generator', /joomla/i],
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: 'jQuery',
|
|
76
|
+
categories: ['JavaScript libraries'],
|
|
77
|
+
script: [/jquery[.-]([\d.]+)(?:\.min)?\.js/i],
|
|
78
|
+
versionFrom: { surface: 'script', regex: /jquery[.-]([\d]+(?:\.[\d]+)*)/i },
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: 'React',
|
|
82
|
+
categories: ['JavaScript frameworks'],
|
|
83
|
+
script: [/\breact(?:\.production|\.development)?(?:\.min)?\.js/i],
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: 'Vue.js',
|
|
87
|
+
categories: ['JavaScript frameworks'],
|
|
88
|
+
script: [/\bvue(?:@[\d.]+)?(?:\.global|\.runtime)?(?:\.min)?\.js/i],
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: 'Cloudflare',
|
|
92
|
+
categories: ['CDN'],
|
|
93
|
+
header: ['server', /cloudflare/i],
|
|
94
|
+
altHeader: ['cf-ray', /.+/],
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
// Build a lookup map from name β sig for implies resolution.
|
|
99
|
+
const SIG_BY_NAME = new Map(SIGS.map(s => [s.name, s]));
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Helpers
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
/** Normalise headers: lowercase keys, join array values. */
|
|
106
|
+
function normaliseHeaders(raw = {}) {
|
|
107
|
+
const out = {};
|
|
108
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
109
|
+
out[k.toLowerCase()] = Array.isArray(v) ? v.join('; ') : String(v);
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Extract all <script src="β¦"> URLs from HTML (case-insensitive). */
|
|
115
|
+
function extractScriptSrcs(html) {
|
|
116
|
+
const srcs = [];
|
|
117
|
+
const re = /<script[^>]+\bsrc\s*=\s*["']([^"']+)["']/gi;
|
|
118
|
+
let m;
|
|
119
|
+
while ((m = re.exec(html)) !== null) srcs.push(m[1]);
|
|
120
|
+
return srcs;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Extract <meta name="β¦" content="β¦"> into a lowercased-name β content map. */
|
|
124
|
+
function extractMetas(html) {
|
|
125
|
+
const map = {};
|
|
126
|
+
const re = /<meta[^>]+>/gi; // NOTE: stops at first '>'; meta content containing '>' is not handled (no current signature needs it)
|
|
127
|
+
let m;
|
|
128
|
+
while ((m = re.exec(html)) !== null) {
|
|
129
|
+
const tag = m[0];
|
|
130
|
+
const nameM = /\bname\s*=\s*["']([^"']+)["']/i.exec(tag);
|
|
131
|
+
const contM = /\bcontent\s*=\s*["']([^"']*)["']/i.exec(tag);
|
|
132
|
+
if (nameM && contM) map[nameM[1].toLowerCase()] = contM[1];
|
|
133
|
+
}
|
|
134
|
+
return map;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Core export
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Fingerprint a web response.
|
|
143
|
+
* @param {{ url?, html?, statusCode?, headers? }} probe
|
|
144
|
+
* @returns {{ name, categories, confidence, version }[]}
|
|
145
|
+
*/
|
|
146
|
+
export function fingerprint({ url, html = '', statusCode, headers = {} } = {}) {
|
|
147
|
+
const hdrs = normaliseHeaders(headers);
|
|
148
|
+
const srcs = extractScriptSrcs(html);
|
|
149
|
+
const metas = extractMetas(html);
|
|
150
|
+
// url + statusCode reserved for future per-URL / status-code rules
|
|
151
|
+
const cookie = hdrs['set-cookie'] ?? ''; // set-cookie is multi-valued; joined for regex matching β cookie patterns should match anywhere in the joined string.
|
|
152
|
+
|
|
153
|
+
/** Resolve version for a detected sig given the surfaces already parsed. */
|
|
154
|
+
function resolveVersion(sig) {
|
|
155
|
+
const vf = sig.versionFrom;
|
|
156
|
+
if (!vf) return undefined;
|
|
157
|
+
let haystack = '';
|
|
158
|
+
if (vf.surface === 'header') {
|
|
159
|
+
haystack = hdrs[vf.key ?? ''] ?? '';
|
|
160
|
+
} else if (vf.surface === 'meta') {
|
|
161
|
+
haystack = metas[vf.key ?? ''] ?? '';
|
|
162
|
+
} else if (vf.surface === 'script') {
|
|
163
|
+
// Test every src URL; return the first capture.
|
|
164
|
+
for (const src of srcs) {
|
|
165
|
+
const vm = vf.regex.exec(src);
|
|
166
|
+
if (vm) return vm[1];
|
|
167
|
+
}
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
const vm = vf.regex.exec(haystack);
|
|
171
|
+
return vm ? vm[1] : undefined;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Count how many distinct surfaces match for a sig. */
|
|
175
|
+
function countSurfaces(sig) {
|
|
176
|
+
let count = 0;
|
|
177
|
+
|
|
178
|
+
// header
|
|
179
|
+
if (sig.header) {
|
|
180
|
+
const [hName, hRe] = sig.header;
|
|
181
|
+
if (hdrs[hName] && hRe.test(hdrs[hName])) count++;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// altHeader β only counts if it's a different surface from header
|
|
185
|
+
if (sig.altHeader) {
|
|
186
|
+
const [hName, hRe] = sig.altHeader;
|
|
187
|
+
if (hdrs[hName] && hRe.test(hdrs[hName])) count++;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// meta
|
|
191
|
+
if (sig.meta) {
|
|
192
|
+
const [mName, mRe] = sig.meta;
|
|
193
|
+
const val = metas[mName.toLowerCase()];
|
|
194
|
+
if (val && mRe.test(val)) count++;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// script β counts as 1 surface if any src URL matches any pattern
|
|
198
|
+
if (sig.script) {
|
|
199
|
+
const scriptHit = sig.script.some(re => srcs.some(src => re.test(src)));
|
|
200
|
+
if (scriptHit) count++;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// cookie β counts as 1 surface if any pattern matches
|
|
204
|
+
if (sig.cookie) {
|
|
205
|
+
const cookieHit = sig.cookie.some(re => re.test(cookie));
|
|
206
|
+
if (cookieHit) count++;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return count;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// --- Detect ---
|
|
213
|
+
const detected = new Map(); // name β result object
|
|
214
|
+
|
|
215
|
+
for (const sig of SIGS) {
|
|
216
|
+
const surfaces = countSurfaces(sig);
|
|
217
|
+
if (surfaces < 1) continue;
|
|
218
|
+
|
|
219
|
+
const confidence = Math.min(100, 25 + 25 * surfaces);
|
|
220
|
+
const version = resolveVersion(sig);
|
|
221
|
+
|
|
222
|
+
detected.set(sig.name, {
|
|
223
|
+
name: sig.name,
|
|
224
|
+
categories: [...sig.categories],
|
|
225
|
+
confidence,
|
|
226
|
+
version,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// --- Resolve implies ---
|
|
231
|
+
for (const sig of SIGS) {
|
|
232
|
+
if (!sig.implies || !detected.has(sig.name)) continue;
|
|
233
|
+
for (const impliedName of sig.implies) {
|
|
234
|
+
if (detected.has(impliedName)) continue;
|
|
235
|
+
const impliedSig = SIG_BY_NAME.get(impliedName);
|
|
236
|
+
if (!impliedSig) continue;
|
|
237
|
+
detected.set(impliedName, {
|
|
238
|
+
name: impliedName,
|
|
239
|
+
categories: [...impliedSig.categories],
|
|
240
|
+
confidence: 50,
|
|
241
|
+
version: undefined,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return Array.from(detected.values());
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export default fingerprint;
|