unk-scanner 0.0.0-stage → 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/LICENSE +25 -0
- package/README.md +58 -2
- package/bin/unk-scanner.mjs +6 -0
- package/package.json +34 -4
- package/src/artifacts.mjs +24 -0
- package/src/bounded.mjs +33 -0
- package/src/cli.mjs +57 -0
- package/src/inspect.mjs +101 -0
- package/src/loaders.mjs +68 -0
- package/src/worker.mjs +8 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 UNK_DeadDrop research contributors
|
|
4
|
+
|
|
5
|
+
This license applies only to original code, tests, configuration and detection
|
|
6
|
+
rules in this companion repository. It does not cover research prose or any
|
|
7
|
+
third-party evidence.
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,59 @@
|
|
|
1
|
-
#
|
|
1
|
+
# unk-scanner
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Local, static JavaScript triage based on the [UNK_DeadDrop investigation](https://krasforge.github.io/UNK_DeadDrop/). Acorn parses the input without executing it. The scanner reports literal and allowlisted identifier campaign markers, syntactic clipboard writes, and a specific encrypted-loader pattern with one-based source locations.
|
|
4
|
+
|
|
5
|
+
## Install and run
|
|
6
|
+
|
|
7
|
+
Requires Node.js 22 or later. Version 0.1.0 is prepared for release; npm publication is pending. Install the locally built tarball:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install --global ./unk-scanner-0.1.0.tgz --ignore-scripts
|
|
11
|
+
unk-scanner suspect.js > findings.json
|
|
12
|
+
unk-scanner --help
|
|
13
|
+
unk-scanner --version
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Use `unk-scanner -- ./-filename.js` for option-like paths. The command accepts one UTF-8 JavaScript file, up to 8 MiB, and emits JSON. For gzip-compressed HTTP bodies, use `unk-scanner --gzip input.js`. Both compressed and decoded sizes are capped at 8 MiB. Without the flag, gzip magic produces an actionable error. Exit status 0 means analysis completed, including when findings exist. Status 2 means invalid arguments, unreadable input, encoding failure, or parse failure. Errors go to stderr.
|
|
17
|
+
|
|
18
|
+
## Findings and limits
|
|
19
|
+
|
|
20
|
+
With `--gzip`, top-level hashes and sizes describe the original compressed input; `decoded` records the decoded SHA-256, byte count, and encoding. Locations refer to decoded JavaScript.
|
|
21
|
+
|
|
22
|
+
The JSON includes schemaVersion, the original file's SHA-256, byte count, findings, and limitations. Each finding contains kind, line, column, and detail. Clipboard literal details are truncated to 240 JavaScript string code units; dynamic arguments are marked unresolved.
|
|
23
|
+
|
|
24
|
+
The scanner folds string literals, templates without interpolation, and literal concatenation. It supports module syntax with a script-syntax fallback. It does not resolve aliases, track data flow or reachability, decrypt payloads, or decode obfuscated string tables. A marker or clipboard write alone does not establish maliciousness; an empty result does not establish safety.
|
|
25
|
+
|
|
26
|
+
Analysis does not execute or import the input, install its dependencies, or contact remote infrastructure. Output can contain source strings. Handle reports with the input's sensitivity. The CLI parses in a worker with a 10-second deadline and V8 heap limits (256 MiB old generation, 32 MiB young generation). A maximum of 10,000 findings prevents unbounded reports. Limit failures return exit 2 without a partial report. These are application limits, not a security sandbox or a total process-memory guarantee. Use operating-system isolation for untrusted inputs.
|
|
27
|
+
|
|
28
|
+
## Encrypted-loader findings
|
|
29
|
+
|
|
30
|
+
An `encrypted-loader` finding links a specific top-level sequence: a literal base64 buffer, its IV/tag/ciphertext slices, an AES-256-GCM decipher, authentication-tag assignment, conversion of update/final output to text, and an `_compile` call receiving that text. Evidence locations identify five steps without copying keys or encrypted payloads into the report.
|
|
31
|
+
|
|
32
|
+
Recognition follows direct top-level `const` bindings. It tolerates renamed variables and literal-computed method names. It does not evaluate expressions, decrypt anything, establish that receivers are genuine Node APIs, or prove execution or successful authentication. Other loader layouts, nested scopes, mutable bindings, and string-table obfuscation may remain unmatched. Encryption or compilation alone does not trigger this finding; it is not campaign attribution.
|
|
33
|
+
|
|
34
|
+
## Exact artifact identity
|
|
35
|
+
|
|
36
|
+
`artifactMatches` is separate from `findings`. It identifies three exact decoded-source hashes retained in the investigation: the login interface, a shared module-preload polyfill, and the obfuscated extension entry point. The login interface has classification `context-only`; the polyfill is a `shared-web-component`. Neither is a malware alert or an attribution indicator. The extension match identifies the retained bytes but does not resolve its behavior.
|
|
37
|
+
|
|
38
|
+
With gzip input, identity matches use the decoded source hash, while the top-level hash still describes the original input. Any source-byte change invalidates exact identity. Do not merge identity counts into behavioral detection or recall statistics. Review context at the [research series](https://krasforge.github.io/UNK_DeadDrop/).
|
|
39
|
+
|
|
40
|
+
## JavaScript API
|
|
41
|
+
|
|
42
|
+
```js
|
|
43
|
+
import { inspect } from 'unk-scanner';
|
|
44
|
+
const result = inspect('navigator.clipboard.writeText("example");');
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The synchronous API runs in the caller process without the CLI worker deadline or heap limits. The API accepts source text and hashes its UTF-8 encoding. The CLI hashes original file bytes, including any UTF-8 byte-order mark.
|
|
48
|
+
|
|
49
|
+
## Development
|
|
50
|
+
|
|
51
|
+
From the companion repository root:
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
npm ci --ignore-scripts
|
|
55
|
+
npm test
|
|
56
|
+
npm pack --workspace unk-scanner
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Code is MIT licensed. Acorn retains its own license. This package includes no malware samples, YARA rules, or credentials. Existing YARA validation statistics do not measure scanner accuracy.
|
package/package.json
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unk-scanner",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Static JavaScript triage for UNK_DeadDrop campaign markers and clipboard writes",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"unk-scanner": "bin/unk-scanner.mjs"
|
|
12
|
+
},
|
|
13
|
+
"exports": "./src/inspect.mjs",
|
|
14
|
+
"files": [
|
|
15
|
+
"bin/",
|
|
16
|
+
"src/",
|
|
17
|
+
"LICENSE",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"acorn": "8.17.0"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/KrasForge/unk-deaddrop-companion.git",
|
|
26
|
+
"directory": "packages/unk-scanner"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://krasforge.github.io/UNK_DeadDrop/",
|
|
29
|
+
"keywords": [
|
|
30
|
+
"security",
|
|
31
|
+
"static-analysis",
|
|
32
|
+
"javascript",
|
|
33
|
+
"acorn",
|
|
34
|
+
"unk-deaddrop"
|
|
35
|
+
]
|
|
36
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Exact decoded-source identities. These describe retained evidence, not
|
|
2
|
+
// generalized malware signatures. Shared web components must not be alerts.
|
|
3
|
+
const artifacts = new Map([
|
|
4
|
+
['5557912dedf7f75666683afbe2632e0b89b8ee23f621432296a9c48eff63e9f4', {
|
|
5
|
+
name: 'Retained Domatisc login interface',
|
|
6
|
+
classification: 'context-only',
|
|
7
|
+
explanation: 'Login and animation code retained from the observed panel. Its content alone does not establish malicious behavior.',
|
|
8
|
+
}],
|
|
9
|
+
['785597bca2e7dd97a652a20a197c077079e635a5663433448e0e1d9fd8c98a64', {
|
|
10
|
+
name: 'Retained module-preload polyfill',
|
|
11
|
+
classification: 'shared-web-component',
|
|
12
|
+
explanation: 'Generic module-preload compatibility code. An identical copy elsewhere does not establish a campaign association.',
|
|
13
|
+
}],
|
|
14
|
+
['d5e9288693aa745dc89368deac677e7ea1ec81e663283af30838cdae189b7a7e', {
|
|
15
|
+
name: 'Retained google-dev-tools extension entry point',
|
|
16
|
+
classification: 'known-investigation-artifact',
|
|
17
|
+
explanation: 'Exact retained obfuscated extension source. This match identifies these bytes; the scanner has not resolved its runtime behavior.',
|
|
18
|
+
}],
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
export function identifyArtifact(sha256) {
|
|
22
|
+
const artifact = artifacts.get(sha256);
|
|
23
|
+
return artifact ? [{ ...artifact, match: 'exact-source-sha256', sha256 }] : [];
|
|
24
|
+
}
|
package/src/bounded.mjs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Worker } from 'node:worker_threads';
|
|
2
|
+
|
|
3
|
+
// A worker bounds CLI parser runtime and V8 heap use. It never imports the input.
|
|
4
|
+
export function inspectBounded(source, { timeoutMs = 10000 } = {}) {
|
|
5
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60000) {
|
|
6
|
+
return Promise.reject(new Error('Timeout must be an integer from 1 to 60000 ms'));
|
|
7
|
+
}
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
const worker = new Worker(new URL('./worker.mjs', import.meta.url), {
|
|
10
|
+
workerData: source,
|
|
11
|
+
resourceLimits: { maxOldGenerationSizeMb: 256, maxYoungGenerationSizeMb: 32, stackSizeMb: 4 },
|
|
12
|
+
execArgv: [],
|
|
13
|
+
});
|
|
14
|
+
let finished = false;
|
|
15
|
+
const finish = (error, result) => {
|
|
16
|
+
if (finished) return;
|
|
17
|
+
finished = true;
|
|
18
|
+
clearTimeout(timer);
|
|
19
|
+
void worker.terminate();
|
|
20
|
+
if (error) reject(error);
|
|
21
|
+
else resolve(result);
|
|
22
|
+
};
|
|
23
|
+
const timer = setTimeout(() => finish(new Error('Analysis timed out; no complete result')), timeoutMs);
|
|
24
|
+
worker.once('message', message => {
|
|
25
|
+
if (message.error) finish(new Error(message.error));
|
|
26
|
+
else finish(null, message.result);
|
|
27
|
+
});
|
|
28
|
+
worker.once('error', error => finish(new Error('Analysis worker failed: ' + error.message)));
|
|
29
|
+
worker.once('exit', code => {
|
|
30
|
+
if (!finished) finish(new Error('Analysis worker exited without a result (code ' + code + ')'));
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { open, readFile } from 'node:fs/promises';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { MAX_BYTES } from './inspect.mjs';
|
|
5
|
+
import { inspectBounded } from './bounded.mjs';
|
|
6
|
+
import { gunzipSync } from 'node:zlib';
|
|
7
|
+
const { version } = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
8
|
+
|
|
9
|
+
export async function main(args) {
|
|
10
|
+
if (args.length === 1 && ['--help', '-h'].includes(args[0])) {
|
|
11
|
+
process.stdout.write('Usage: unk-scanner [--gzip] [--] FILE.js\n unk-scanner --help | --version\n\nStatic JavaScript triage; emits JSON. Findings are review leads.\nExit 0: analysis completed (with or without findings). Exit 2: error.\n');
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
if (args.length === 1 && ['--version', '-v'].includes(args[0])) {
|
|
15
|
+
process.stdout.write(version + '\n');
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const gzip = args[0] === '--gzip';
|
|
19
|
+
if (gzip) args = args.slice(1);
|
|
20
|
+
if (args[0] === '--') args = args.slice(1);
|
|
21
|
+
else if (args[0]?.startsWith('-')) throw new Error('Unknown option: ' + args[0]);
|
|
22
|
+
if (args.length !== 1) throw new Error('Usage: unk-scanner [--gzip] [--] FILE.js');
|
|
23
|
+
const handle = await open(args[0], constants.O_RDONLY | constants.O_NONBLOCK);
|
|
24
|
+
let bytes;
|
|
25
|
+
try {
|
|
26
|
+
const stat = await handle.stat();
|
|
27
|
+
if (!stat.isFile()) throw new Error('Input must be a regular file');
|
|
28
|
+
if (stat.size > MAX_BYTES) throw new Error('Input exceeds 8 MiB limit');
|
|
29
|
+
// Read at most the limit plus one, including if the input grows after stat().
|
|
30
|
+
const buffer = Buffer.alloc(MAX_BYTES + 1);
|
|
31
|
+
let count = 0;
|
|
32
|
+
while (count < buffer.length) {
|
|
33
|
+
const read = await handle.read(buffer, count, buffer.length - count, null);
|
|
34
|
+
if (!read.bytesRead) break;
|
|
35
|
+
count += read.bytesRead;
|
|
36
|
+
}
|
|
37
|
+
if (count > MAX_BYTES) throw new Error('Input exceeds 8 MiB limit');
|
|
38
|
+
bytes = buffer.subarray(0, count);
|
|
39
|
+
} finally {
|
|
40
|
+
await handle.close();
|
|
41
|
+
}
|
|
42
|
+
if (!gzip && bytes[0] === 0x1f && bytes[1] === 0x8b) {
|
|
43
|
+
throw new Error('Gzip input detected; use --gzip to decode with an 8 MiB output limit');
|
|
44
|
+
}
|
|
45
|
+
const decoded = gzip ? gunzipSync(bytes, { maxOutputLength: MAX_BYTES }) : bytes;
|
|
46
|
+
const source = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(decoded);
|
|
47
|
+
const result = await inspectBounded(source);
|
|
48
|
+
if (gzip) {
|
|
49
|
+
result.decoded = {
|
|
50
|
+
encoding: 'gzip', bytes: decoded.length,
|
|
51
|
+
sha256: createHash('sha256').update(decoded).digest('hex'),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
result.sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
55
|
+
result.bytes = bytes.length;
|
|
56
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
57
|
+
}
|
package/src/inspect.mjs
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { identifyArtifact } from './artifacts.mjs';
|
|
2
|
+
import { encryptedLoaders } from './loaders.mjs';
|
|
3
|
+
import { parse } from 'acorn';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
export const MAX_FINDINGS = 10000;
|
|
7
|
+
export const MAX_BYTES = 8 * 1024 * 1024;
|
|
8
|
+
const MARKERS = /OVERLORD_COMPANY_WALLET_EP|OVERLORD_SERVER_RAW|OVERLORD_KEEP_AGENT_STATE|X-Company-Wallet-Client-Id|\/api\/company-wallet\/upload|GoogleUpdateSupporterNode|GUS_UPLOAD_CHUNK_SIZE|DEFAULT_PHASE3_MAX_MS|DEFAULT_PHASE3_ROOT_MAX_MS|WIN_NODE_AGENT_BUNDLE_B64|DEFAULT_WALK_MAX_DIRS|CREDENTIAL_NAME_RE|macTccReadableCache|CloudStorage|ELECTRON_RUN_AS_NODE|--company-wallet-phase1-local|--company-wallet-job|--win-build-ep-only|cmdline_copied|perl -MPOSIX|POSIX::setsid|curl -ks|start -WindowStyle Hidden cmd|VERIFY-|IElevator|detect_malware\.py|3E5FC7F9-9A51-4367-9063-A120244FBEC7|gus-phase2-|sa-elev-done-|app_bound_encrypted_key/g;
|
|
9
|
+
|
|
10
|
+
// Only literal syntax is folded. No variable resolution, calls or coercion.
|
|
11
|
+
export function literalString(node, depth = 0) {
|
|
12
|
+
if (!node || depth > 32) return undefined;
|
|
13
|
+
if (node.type === 'Literal' && typeof node.value === 'string') return node.value;
|
|
14
|
+
if (node.type === 'TemplateLiteral' && node.expressions.length === 0) {
|
|
15
|
+
return node.quasis[0].value.cooked ?? undefined;
|
|
16
|
+
}
|
|
17
|
+
if (node.type === 'BinaryExpression' && node.operator === '+') {
|
|
18
|
+
const left = literalString(node.left, depth + 1);
|
|
19
|
+
const right = literalString(node.right, depth + 1);
|
|
20
|
+
if (left !== undefined && right !== undefined) return left + right;
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
const IDENTIFIER_MARKERS = new Set([
|
|
25
|
+
'OVERLORD_COMPANY_WALLET_EP', 'OVERLORD_SERVER_RAW', 'OVERLORD_KEEP_AGENT_STATE',
|
|
26
|
+
'GUS_UPLOAD_CHUNK_SIZE', 'DEFAULT_PHASE3_MAX_MS', 'DEFAULT_PHASE3_ROOT_MAX_MS',
|
|
27
|
+
'WIN_NODE_AGENT_BUNDLE_B64', 'DEFAULT_WALK_MAX_DIRS', 'CREDENTIAL_NAME_RE',
|
|
28
|
+
'macTccReadableCache', 'ELECTRON_RUN_AS_NODE', 'app_bound_encrypted_key',
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
function memberName(node) {
|
|
32
|
+
while (node?.type === 'ChainExpression') node = node.expression;
|
|
33
|
+
if (node?.type !== 'MemberExpression') return undefined;
|
|
34
|
+
return node.computed ? literalString(node.property) : node.property.name;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function inspect(source) {
|
|
38
|
+
if (typeof source !== 'string') throw new TypeError('Expected JavaScript text');
|
|
39
|
+
if (Buffer.byteLength(source) > MAX_BYTES) throw new Error('Input exceeds 8 MiB limit');
|
|
40
|
+
let ast;
|
|
41
|
+
try {
|
|
42
|
+
ast = parse(source, { ecmaVersion: 'latest', sourceType: 'module', locations: true });
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (!(error instanceof SyntaxError)) throw error;
|
|
45
|
+
ast = parse(source, { ecmaVersion: 'latest', sourceType: 'script', locations: true });
|
|
46
|
+
}
|
|
47
|
+
const findings = [];
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
const add = (kind, node, detail, extra = {}) => {
|
|
50
|
+
const key = `${kind}:${node.start}:${detail}`;
|
|
51
|
+
if (seen.has(key)) return;
|
|
52
|
+
if (findings.length >= MAX_FINDINGS) throw new Error('Finding limit exceeded; no complete result');
|
|
53
|
+
seen.add(key);
|
|
54
|
+
findings.push({ kind, line: node.loc.start.line, column: node.loc.start.column + 1, detail, ...extra });
|
|
55
|
+
};
|
|
56
|
+
const pending = [ast];
|
|
57
|
+
while (pending.length) {
|
|
58
|
+
const node = pending.pop();
|
|
59
|
+
if (node.type === 'Identifier' && IDENTIFIER_MARKERS.has(node.name)) {
|
|
60
|
+
add('campaign-marker', node, node.name);
|
|
61
|
+
}
|
|
62
|
+
const value = literalString(node);
|
|
63
|
+
if (value !== undefined) {
|
|
64
|
+
for (const match of value.matchAll(MARKERS)) add('campaign-marker', node, match[0]);
|
|
65
|
+
}
|
|
66
|
+
if (node.type === 'CallExpression') {
|
|
67
|
+
const callee = node.callee.type === 'ChainExpression' ? node.callee.expression : node.callee;
|
|
68
|
+
const method = memberName(callee);
|
|
69
|
+
const owner = memberName(callee?.object);
|
|
70
|
+
if ((method === 'writeText' && owner === 'clipboard') ||
|
|
71
|
+
(method === 'setData' && owner === 'clipboardData')) {
|
|
72
|
+
const arg = node.arguments[method === 'setData' ? 1 : 0];
|
|
73
|
+
const text = literalString(arg);
|
|
74
|
+
add('clipboard-write', node, text === undefined ? 'dynamic argument; unresolved' : text.slice(0, 240));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
for (const value of Object.values(node)) {
|
|
78
|
+
if (Array.isArray(value)) {
|
|
79
|
+
for (const child of value) if (child?.type) pending.push(child);
|
|
80
|
+
} else if (value?.type) pending.push(value);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
for (const loader of encryptedLoaders(ast, literalString)) {
|
|
84
|
+
add('encrypted-loader', loader.node,
|
|
85
|
+
'Embedded base64, AES-GCM authentication, and decoded text linked syntactically to _compile',
|
|
86
|
+
{ evidence: loader.evidence });
|
|
87
|
+
}
|
|
88
|
+
findings.sort((a, b) => a.line - b.line || a.column - b.column || a.kind.localeCompare(b.kind));
|
|
89
|
+
const sha256 = createHash('sha256').update(source).digest('hex');
|
|
90
|
+
return {
|
|
91
|
+
schemaVersion: 1,
|
|
92
|
+
sha256,
|
|
93
|
+
artifactMatches: identifyArtifact(sha256),
|
|
94
|
+
bytes: Buffer.byteLength(source),
|
|
95
|
+
findings,
|
|
96
|
+
limitations: ['Syntactic matches only; no data-flow or reachability proof',
|
|
97
|
+
'No general alias resolution or encoded string-table deobfuscation',
|
|
98
|
+
'Encrypted-loader recognition covers only a specific top-level const-binding pattern',
|
|
99
|
+
'A clipboard write or marker alone is not a maliciousness verdict'],
|
|
100
|
+
};
|
|
101
|
+
}
|
package/src/loaders.mjs
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Recognize a narrow top-level const-binding pattern. Names may vary; input is
|
|
2
|
+
// never evaluated. This establishes syntax relationships, not runtime behavior.
|
|
3
|
+
export function encryptedLoaders(ast, literalString) {
|
|
4
|
+
const bindings = new Map();
|
|
5
|
+
const calls = [];
|
|
6
|
+
const method = node => node?.type === 'MemberExpression'
|
|
7
|
+
? (node.computed ? literalString(node.property) : node.property.name) : undefined;
|
|
8
|
+
const id = node => node?.type === 'Identifier' ? node.name : undefined;
|
|
9
|
+
const isCall = (node, name) => node?.type === 'CallExpression' && method(node.callee) === name;
|
|
10
|
+
const binding = (node, before) => {
|
|
11
|
+
const found = bindings.get(id(node));
|
|
12
|
+
return found && found.start < before ? found.init : undefined;
|
|
13
|
+
};
|
|
14
|
+
const collect = expression => {
|
|
15
|
+
if (expression?.type === 'SequenceExpression') expression.expressions.forEach(collect);
|
|
16
|
+
else if (expression?.type === 'CallExpression') calls.push(expression);
|
|
17
|
+
};
|
|
18
|
+
for (const statement of ast.body) {
|
|
19
|
+
if (statement.type === 'VariableDeclaration' && statement.kind === 'const') {
|
|
20
|
+
for (const declaration of statement.declarations) {
|
|
21
|
+
if (declaration.id.type === 'Identifier') bindings.set(declaration.id.name, declaration);
|
|
22
|
+
}
|
|
23
|
+
} else if (statement.type === 'ExpressionStatement') collect(statement.expression);
|
|
24
|
+
}
|
|
25
|
+
const slice = (node, before, start, end) => {
|
|
26
|
+
const init = binding(node, before);
|
|
27
|
+
if (!isCall(init, 'subarray')) return undefined;
|
|
28
|
+
const args = init.arguments;
|
|
29
|
+
if (args.length !== (end === undefined ? 1 : 2) ||
|
|
30
|
+
args[0]?.type !== 'Literal' || args[0].value !== start ||
|
|
31
|
+
(end !== undefined && (args[1]?.type !== 'Literal' || args[1].value !== end))) return undefined;
|
|
32
|
+
return id(init.callee.object);
|
|
33
|
+
};
|
|
34
|
+
const results = [];
|
|
35
|
+
for (const compile of calls) {
|
|
36
|
+
if (!isCall(compile, '_compile')) continue;
|
|
37
|
+
const source = binding(compile.arguments[0], compile.start);
|
|
38
|
+
if (!isCall(source, 'toString') || !['utf8', 'utf-8'].includes(literalString(source.arguments[0]))) continue;
|
|
39
|
+
const concat = source.callee.object;
|
|
40
|
+
if (!isCall(concat, 'concat') || id(concat.callee.object) !== 'Buffer' ||
|
|
41
|
+
concat.arguments.length !== 1 || concat.arguments[0].type !== 'ArrayExpression') continue;
|
|
42
|
+
const parts = concat.arguments[0].elements;
|
|
43
|
+
if (parts.length !== 2 || !isCall(parts[0], 'update') || !isCall(parts[1], 'final')) continue;
|
|
44
|
+
const decipherName = id(parts[0].callee.object);
|
|
45
|
+
if (!decipherName || id(parts[1].callee.object) !== decipherName) continue;
|
|
46
|
+
const decipher = binding(parts[0].callee.object, source.start);
|
|
47
|
+
if (!isCall(decipher, 'createDecipheriv') || literalString(decipher.arguments[0]) !== 'aes-256-gcm') continue;
|
|
48
|
+
const payloadName = slice(decipher.arguments[2], decipher.start, 0, 12);
|
|
49
|
+
if (!payloadName || slice(parts[0].arguments[0], source.start, 28) !== payloadName) continue;
|
|
50
|
+
const payload = bindings.get(payloadName);
|
|
51
|
+
if (!payload || payload.start >= decipher.start || !isCall(payload.init, 'from') ||
|
|
52
|
+
id(payload.init.callee.object) !== 'Buffer' ||
|
|
53
|
+
literalString(payload.init.arguments[1]) !== 'base64' ||
|
|
54
|
+
literalString(payload.init.arguments[0]) === undefined) continue;
|
|
55
|
+
const tag = calls.find(call => isCall(call, 'setAuthTag') &&
|
|
56
|
+
id(call.callee.object) === decipherName && call.start > decipher.start && call.start < source.start &&
|
|
57
|
+
slice(call.arguments[0], call.start, 12, 28) === payloadName);
|
|
58
|
+
if (!tag) continue;
|
|
59
|
+
results.push({
|
|
60
|
+
node: compile,
|
|
61
|
+
evidence: [
|
|
62
|
+
['embedded-base64', payload.init], ['aes-256-gcm', decipher],
|
|
63
|
+
['authentication-tag', tag], ['decoded-text', source], ['module-compile', compile],
|
|
64
|
+
].map(([role, node]) => ({ role, line: node.loc.start.line, column: node.loc.start.column + 1 })),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return results;
|
|
68
|
+
}
|
package/src/worker.mjs
ADDED