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/detector.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// packages/fw-agent/src/detector.js
|
|
2
|
+
const { AhoCorasick } = require('./aho-corasick');
|
|
3
|
+
const { BehaviorTracker } = require('./behavior-tracker');
|
|
4
|
+
|
|
5
|
+
// Extended signature set covers crypto-miners, dynamic code execution, network abuse,
|
|
6
|
+
// and supply-chain worm patterns (postinstall fetchers, credential harvesters).
|
|
7
|
+
// High-confidence malicious signatures — trigger QUARANTINE/BLOCK on match.
|
|
8
|
+
const BLOCK_SIGNATURES = [
|
|
9
|
+
'/dev/tcp/',
|
|
10
|
+
// Reverse-shell redirect idiom (F-29): bare 'bash -i' / 'sh -i' also match ordinary
|
|
11
|
+
// interactive-shell invocations of unrelated tools (push -i, fish -i, wash -i, ...).
|
|
12
|
+
// Real reverse shells redirect stdio via '>&' — require that too.
|
|
13
|
+
'bash -i >&',
|
|
14
|
+
'sh -i >&',
|
|
15
|
+
// Crypto-miner pool identifiers. F-29: bare 'stratum' matches the mining-protocol word
|
|
16
|
+
// wherever it occurs in English prose (e.g. dictionary/word-list packages containing
|
|
17
|
+
// "stratum", "substratum", "stratus"), so match the pool-URL scheme instead — that's
|
|
18
|
+
// what real miner configs actually contain.
|
|
19
|
+
'stratum+tcp',
|
|
20
|
+
'stratum://',
|
|
21
|
+
'pool.hashvault',
|
|
22
|
+
'coin-hive',
|
|
23
|
+
'xmr-stak',
|
|
24
|
+
'nicehash',
|
|
25
|
+
'coinhive',
|
|
26
|
+
'cryptonight',
|
|
27
|
+
// Additional browser/pool miner brands with no stratum literal (red-team group B). These
|
|
28
|
+
// are distinctive product names that do not occur in ordinary prose or legitimate code.
|
|
29
|
+
'coinimp',
|
|
30
|
+
'jsecoin',
|
|
31
|
+
'webminepool',
|
|
32
|
+
'deepminer',
|
|
33
|
+
// Supply-chain worm indicators
|
|
34
|
+
'//pastebin',
|
|
35
|
+
'//paste.ee',
|
|
36
|
+
'| bash',
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
// Regex-tier block signatures for idioms that must be anchored beyond a literal substring.
|
|
40
|
+
// A bare '| sh' literal in the Aho-Corasick set would false-match '| shorten', '| sha256sum',
|
|
41
|
+
// '| ssh', etc.; the \b after the shell name prevents that. Covers the sh/dash/zsh stagers the
|
|
42
|
+
// literal '| bash' above misses (revsh-wget-pipe-sh, sc-preinstall-curl-sh): piping a
|
|
43
|
+
// downloaded payload straight into a shell is a high-confidence stager.
|
|
44
|
+
const BLOCK_REGEXES = [
|
|
45
|
+
// Require whitespace after the pipe (`| sh`, not `|sh`): a `|word|word|` token list — e.g.
|
|
46
|
+
// he.js's HTML-entity table `|dArr|dash|Sqrt|` — otherwise matches `|dash` as `| da + sh`.
|
|
47
|
+
// All real stagers in the corpus (`curl … | sh`, `wget … | sh`) space the pipe. `|sh` without
|
|
48
|
+
// a space is a documented residual gap (see THREAT-COVERAGE.md).
|
|
49
|
+
{ re: /\|\s+(?:ba|da|z)?sh\b/i, type: 'dynamic-code-exec', severity: 'HIGH', label: 'pipe-to-shell-stager' },
|
|
50
|
+
// Reverse-shell tooling beyond /dev/tcp (red-team group E). Each is anchored to the exact
|
|
51
|
+
// exploit idiom (a flag, a scheme, or an API path) so it cannot match ordinary prose or
|
|
52
|
+
// legitimate command strings — none of these occur in benign npm module source.
|
|
53
|
+
{ re: /\bnc\s+-e\b/i, type: 'reverse-shell', severity: 'HIGH', label: 'netcat-exec' },
|
|
54
|
+
{ re: /\bncat\s+(?:--exec|-e)\b/i, type: 'reverse-shell', severity: 'HIGH', label: 'ncat-exec' },
|
|
55
|
+
{ re: /\bsocat\b[^\n]{0,120}EXEC:/i, type: 'reverse-shell', severity: 'HIGH', label: 'socat-exec' },
|
|
56
|
+
{ re: /\bmkfifo\b[^\n]{0,120}\bnc\b/i, type: 'reverse-shell', severity: 'HIGH', label: 'mkfifo-backpipe' },
|
|
57
|
+
{ re: /\bfsockopen\s*\(/i, type: 'reverse-shell', severity: 'HIGH', label: 'php-fsockopen' },
|
|
58
|
+
{ re: /Net\s*\.\s*Sockets\s*\.\s*TCPClient/i, type: 'reverse-shell', severity: 'HIGH', label: 'powershell-tcpclient' },
|
|
59
|
+
{ re: /\bruby\s+-r\s*socket\b/i, type: 'reverse-shell', severity: 'HIGH', label: 'ruby-socket' },
|
|
60
|
+
{ re: /\blua\s+-e\b[^\n]{0,120}os\s*\.\s*execute/i, type: 'reverse-shell', severity: 'HIGH', label: 'lua-socket' },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
// Crypto-miner signal hints — any BLOCK_SIGNATURES hit containing one of these is labeled a
|
|
64
|
+
// crypto-miner (CRITICAL) rather than the generic dynamic-code-exec (HIGH). Previously only
|
|
65
|
+
// stratum/pool/nicehash/cryptonight were treated as crypto, so coinhive/xmr-stak/coin-hive and
|
|
66
|
+
// the brands above were mislabeled dynamic-code-exec. Cosmetic (both still block) but correct.
|
|
67
|
+
const CRYPTO_SIGNAL_HINTS = ['stratum', 'pool', 'nicehash', 'cryptonight', 'coinhive', 'coin-hive', 'xmr-stak', 'coinimp', 'jsecoin', 'webminepool', 'deepminer'];
|
|
68
|
+
|
|
69
|
+
// Indicative patterns common in legitimate code — emit WARN/OBSERVE only, never block.
|
|
70
|
+
// Also includes patterns (exec, eval) that are caught by the behavioral DYNAMIC_CODE_EXEC_CHAIN
|
|
71
|
+
// rule when used dangerously — so static-only matches on these produce WARN, not hard block.
|
|
72
|
+
const WARN_SIGNATURES = [
|
|
73
|
+
'buffer.from',
|
|
74
|
+
'atob(',
|
|
75
|
+
'btoa(',
|
|
76
|
+
'https.request',
|
|
77
|
+
'http.request',
|
|
78
|
+
'net.createconnection',
|
|
79
|
+
'socket.connect',
|
|
80
|
+
// Broad exec/eval literals — moved from BLOCK (F-20): appear in legitimate build tools
|
|
81
|
+
// and test frameworks. Behavioral DYNAMIC_CODE_EXEC_CHAIN still hard-blocks the
|
|
82
|
+
// dangerous eval+exec combination.
|
|
83
|
+
'eval(',
|
|
84
|
+
'child_process.exec',
|
|
85
|
+
'execsync',
|
|
86
|
+
// Legitimate capabilities that false-positive on lodash/axios/express — warn, don't block.
|
|
87
|
+
'new function',
|
|
88
|
+
'process.binding',
|
|
89
|
+
// Legitimate capabilities in bundlers/process libs (esbuild, execa, cross-spawn, ws, undici) — F-26
|
|
90
|
+
'child_process.spawn',
|
|
91
|
+
'spawnsync',
|
|
92
|
+
'vm.runinnewcontext',
|
|
93
|
+
'vm.runinthiscontext',
|
|
94
|
+
// Bare words that also match prose/comments — F-26
|
|
95
|
+
'curl ',
|
|
96
|
+
'wget ',
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
class Detector {
|
|
100
|
+
constructor(/** @reserved - future policy integration */ policyEngine) {
|
|
101
|
+
this.policyEngine = policyEngine;
|
|
102
|
+
this.blockMatcher = new AhoCorasick(BLOCK_SIGNATURES);
|
|
103
|
+
this.warnMatcher = new AhoCorasick(WARN_SIGNATURES);
|
|
104
|
+
this.behaviorTracker = new BehaviorTracker();
|
|
105
|
+
|
|
106
|
+
this.stats = {
|
|
107
|
+
calls: 0,
|
|
108
|
+
automatonScans: 0,
|
|
109
|
+
behaviorViolations: 0,
|
|
110
|
+
warnOnlyDetections: 0,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async scanModule(packageName, moduleContent) {
|
|
115
|
+
return this.scanModuleSync(packageName, moduleContent);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Synchronous O(N) compilation screening combining signature matching and behavioral analysis.
|
|
120
|
+
*/
|
|
121
|
+
scanModuleSync(packageName, moduleContent, filename, packageKey) {
|
|
122
|
+
this.stats.calls++;
|
|
123
|
+
|
|
124
|
+
if (!moduleContent || typeof moduleContent !== 'string') {
|
|
125
|
+
return { action: 'OBSERVE', detections: [], packageName, scanTime: Date.now() };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Full-content scan: Aho-Corasick is O(N) so scanning the entire module is safe.
|
|
129
|
+
const searchContent = moduleContent;
|
|
130
|
+
|
|
131
|
+
this.stats.automatonScans++;
|
|
132
|
+
const detections = [];
|
|
133
|
+
|
|
134
|
+
// BLOCK-tier: high-confidence malicious patterns — always quarantine on match
|
|
135
|
+
const blockMatch = this.blockMatcher.searchInsensitive(searchContent);
|
|
136
|
+
if (blockMatch) {
|
|
137
|
+
const isCrypto = CRYPTO_SIGNAL_HINTS.some(h => blockMatch.includes(h));
|
|
138
|
+
detections.push({
|
|
139
|
+
type: isCrypto ? 'crypto-miner' : 'dynamic-code-exec',
|
|
140
|
+
severity: isCrypto ? 'CRITICAL' : 'HIGH',
|
|
141
|
+
matched: blockMatch,
|
|
142
|
+
timestamp: Date.now(),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Regex-tier block signatures (anchored idioms that literals can't express safely).
|
|
147
|
+
for (const { re, type, severity, label } of BLOCK_REGEXES) {
|
|
148
|
+
if (re.test(searchContent)) {
|
|
149
|
+
detections.push({ type, severity, matched: label, timestamp: Date.now() });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// WARN-tier: common in benign code — log for visibility but never block on these alone
|
|
154
|
+
const warnMatch = this.warnMatcher.searchInsensitive(searchContent);
|
|
155
|
+
if (warnMatch) {
|
|
156
|
+
this.stats.warnOnlyDetections++;
|
|
157
|
+
detections.push({
|
|
158
|
+
type: 'indicative-pattern',
|
|
159
|
+
severity: 'WARN',
|
|
160
|
+
matched: warnMatch,
|
|
161
|
+
timestamp: Date.now(),
|
|
162
|
+
warnOnly: true,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Behavioral sequence analysis on full content (skipped when FW_ENABLE_BEHAVIORAL=0)
|
|
167
|
+
const behaviorEnabled = process.env.FW_ENABLE_BEHAVIORAL !== '0';
|
|
168
|
+
const behaviorViolations = behaviorEnabled
|
|
169
|
+
? this.behaviorTracker.analyzeModule(filename || packageName, moduleContent, packageKey)
|
|
170
|
+
: [];
|
|
171
|
+
|
|
172
|
+
// Cross-file correlation, scoped to this file's package. OPT-IN (FW_ENABLE_CROSSFILE=1,
|
|
173
|
+
// default OFF): soak validation showed it false-positives on large legitimate packages that
|
|
174
|
+
// legitimately split capabilities across files — mongodb reads AWS credentials and hits the
|
|
175
|
+
// instance-metadata endpoint (indistinguishable from exfil), babel/knex generate code in one
|
|
176
|
+
// file and spawn processes in another. Static co-occurrence cannot separate these from a real
|
|
177
|
+
// split attack; that needs Phase 3 taint analysis. Left available for curated dependency sets
|
|
178
|
+
// and mirrored by the registry batch scanner's finalizePackage() (which applies human review).
|
|
179
|
+
const crossFileEnabled = behaviorEnabled && process.env.FW_ENABLE_CROSSFILE === '1';
|
|
180
|
+
if (crossFileEnabled && packageKey !== undefined && packageKey !== null) {
|
|
181
|
+
for (const v of this.behaviorTracker.analyzePackage(packageKey)) {
|
|
182
|
+
behaviorViolations.push(v);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (behaviorViolations.length > 0) {
|
|
186
|
+
this.stats.behaviorViolations++;
|
|
187
|
+
for (const v of behaviorViolations) {
|
|
188
|
+
// CRITICAL/HIGH behavioral violations are block-tier — escalate to quarantine
|
|
189
|
+
if (v.severity === 'CRITICAL' || v.severity === 'HIGH') {
|
|
190
|
+
detections.push({
|
|
191
|
+
type: 'behavioral',
|
|
192
|
+
severity: v.severity,
|
|
193
|
+
rule: v.rule,
|
|
194
|
+
description: v.description,
|
|
195
|
+
timestamp: Date.now(),
|
|
196
|
+
});
|
|
197
|
+
} else {
|
|
198
|
+
// WARN/MEDIUM violations are surfaced for logging and telemetry but never trigger QUARANTINE
|
|
199
|
+
detections.push({
|
|
200
|
+
type: 'behavioral',
|
|
201
|
+
severity: v.severity,
|
|
202
|
+
rule: v.rule,
|
|
203
|
+
description: v.description,
|
|
204
|
+
timestamp: Date.now(),
|
|
205
|
+
warnOnly: true,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Only escalate to QUARANTINE if at least one non-WARN detection exists
|
|
212
|
+
const hasBlockDetection = detections.some(d => !d.warnOnly);
|
|
213
|
+
const action = hasBlockDetection ? 'QUARANTINE' : 'OBSERVE';
|
|
214
|
+
return { action, detections, packageName, scanTime: Date.now(), behaviorViolations };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Batch cross-file finalizer. Call once after scanModuleSync() has run over every file in a
|
|
219
|
+
* package (with reset() between packages). Used by the registry's whole-package scanner
|
|
220
|
+
* (scan-registry.js / watch-changes.js); the runtime firewall does not need it because it runs
|
|
221
|
+
* scoped cross-file inline in scanModuleSync(). With no packageKey, analyzePackage() correlates
|
|
222
|
+
* the whole moduleSignals map — which, given the caller resets per package, is exactly one
|
|
223
|
+
* package's files.
|
|
224
|
+
*/
|
|
225
|
+
finalizePackage() {
|
|
226
|
+
const violations = this.behaviorTracker.analyzePackage();
|
|
227
|
+
if (violations.length > 0) {
|
|
228
|
+
this.stats.behaviorViolations++;
|
|
229
|
+
}
|
|
230
|
+
return violations.map(v => ({
|
|
231
|
+
type: 'behavioral',
|
|
232
|
+
severity: v.severity,
|
|
233
|
+
rule: v.rule,
|
|
234
|
+
description: v.description,
|
|
235
|
+
files: v.files,
|
|
236
|
+
timestamp: Date.now(),
|
|
237
|
+
}));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
static isSuspicious(content) {
|
|
241
|
+
return content && typeof content === 'string' && content.length > 0;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
module.exports = { Detector };
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// packages/fw-agent/src/policy-watcher.js
|
|
2
|
+
// Continuous policy integrity verification using Ed25519 asymmetric signatures.
|
|
3
|
+
//
|
|
4
|
+
// policy.signed.json format:
|
|
5
|
+
// { "version": 1, "rules": {...}, "signedAt": "ISO-8601", "signature": "base64url" }
|
|
6
|
+
//
|
|
7
|
+
// The signature covers the canonical JSON of { version, rules (keys sorted), signedAt }.
|
|
8
|
+
// An invalid or missing signature immediately triggers emergency lockdown.
|
|
9
|
+
// A valid signature with changed rules triggers hot-reload via onValidChange().
|
|
10
|
+
//
|
|
11
|
+
// To sign a policy file:
|
|
12
|
+
// node scripts/sign-policy.js scripts/dev-private-key.pem rules.json policy.signed.json
|
|
13
|
+
//
|
|
14
|
+
// To generate a production key pair:
|
|
15
|
+
// node scripts/generate-policy-key.js
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const crypto = require('crypto');
|
|
19
|
+
|
|
20
|
+
const WATCH_INTERVAL_MS = 60_000;
|
|
21
|
+
|
|
22
|
+
// ── Dev/CI public key ─────────────────────────────────────────────────────────
|
|
23
|
+
// Generated with: node scripts/generate-policy-key.js
|
|
24
|
+
// PRODUCTION: replace with your own key and regenerate .helios-baseline.
|
|
25
|
+
// The private key is in scripts/dev-private-key.pem — DO NOT deploy that file.
|
|
26
|
+
const DEV_PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
|
|
27
|
+
MCowBQYDK2VwAyEANejKx1KxfXVk5B0UzI2Cp3XO9hmy6nIXTAhsW0bhlFo=
|
|
28
|
+
-----END PUBLIC KEY-----`;
|
|
29
|
+
|
|
30
|
+
// Allow the public key to be overridden via environment variable for production deployments.
|
|
31
|
+
// FW_POLICY_PUBKEY must be a PEM-encoded Ed25519 SPKI public key.
|
|
32
|
+
const PUBLIC_KEY_PEM = process.env.FW_POLICY_PUBKEY || DEV_PUBLIC_KEY_PEM;
|
|
33
|
+
|
|
34
|
+
// F-02a: true when we're verifying with the bundled dev key (fallback, or explicitly set).
|
|
35
|
+
// The matching private key (scripts/dev-private-key.pem) is committed to the public repo,
|
|
36
|
+
// so any policy file signed with it is trivially forgeable.
|
|
37
|
+
// Fail loud in start() unless explicitly opted in via FW_ALLOW_DEV_POLICY_KEY=1.
|
|
38
|
+
const USING_DEV_POLICY_KEY = PUBLIC_KEY_PEM.trim() === DEV_PUBLIC_KEY_PEM.trim();
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build the canonical signed payload buffer from a policy object.
|
|
42
|
+
* Keys in rules are sorted alphabetically so the byte sequence is deterministic.
|
|
43
|
+
*/
|
|
44
|
+
function canonicalPayload(version, rules, signedAt) {
|
|
45
|
+
const sorted = {};
|
|
46
|
+
for (const k of Object.keys(rules).sort()) sorted[k] = rules[k];
|
|
47
|
+
return Buffer.from(JSON.stringify({ version, rules: sorted, signedAt }));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* F-33: production dev-key guard that does NOT depend on a policy file being present.
|
|
52
|
+
*
|
|
53
|
+
* The in-`start()` guard below only fires when a policy.signed.json exists (start() returns
|
|
54
|
+
* early otherwise), so a production deploy running the bundled dev key with no policy file on
|
|
55
|
+
* disk got zero signal — even though a policy file could be dropped in later and hot-loaded,
|
|
56
|
+
* and even though shipping the public dev key at all in production is a misconfiguration worth
|
|
57
|
+
* failing loud on. Call this from agent startup, before the watcher, so the check runs
|
|
58
|
+
* regardless of policy-file presence.
|
|
59
|
+
*
|
|
60
|
+
* Refuses to start (process.exit(1)) when running in production against the bundled dev key
|
|
61
|
+
* without an explicit acknowledgement. Local/dev/CI is unaffected: NODE_ENV is not 'production'
|
|
62
|
+
* there, and operators can still opt in with FW_ALLOW_DEV_POLICY_KEY=1.
|
|
63
|
+
*
|
|
64
|
+
* @param {object} [env] - injectable for tests; defaults to process.env
|
|
65
|
+
* @param {function} [exit] - injectable for tests; defaults to process.exit
|
|
66
|
+
* @returns {boolean} true if a refusal was triggered (tests), false otherwise
|
|
67
|
+
*/
|
|
68
|
+
function assertProductionKeyConfig(env = process.env, exit = process.exit) {
|
|
69
|
+
if (env.NODE_ENV === 'production' && USING_DEV_POLICY_KEY && env.FW_ALLOW_DEV_POLICY_KEY !== '1') {
|
|
70
|
+
console.error(
|
|
71
|
+
'[CRITICAL] Running in production (NODE_ENV=production) with the bundled development ' +
|
|
72
|
+
'policy key. The matching private key is public, so any attacker can forge a policy ' +
|
|
73
|
+
'signature. Set FW_POLICY_PUBKEY to your production public key. Refusing to start.'
|
|
74
|
+
);
|
|
75
|
+
exit(1);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
class PolicyWatcher {
|
|
82
|
+
/**
|
|
83
|
+
* @param {string} policyPath - Absolute path to policy.signed.json
|
|
84
|
+
* @param {object} callbacks - { onTamperDetected(), onValidChange(rules) }
|
|
85
|
+
* @param {object} [options] - { intervalMs }
|
|
86
|
+
*/
|
|
87
|
+
constructor(policyPath, callbacks, options = {}) {
|
|
88
|
+
this.policyPath = policyPath;
|
|
89
|
+
this.onTamperDetected = (callbacks && callbacks.onTamperDetected) || (() => {});
|
|
90
|
+
this.onValidChange = (callbacks && callbacks.onValidChange) || (() => {});
|
|
91
|
+
this.locked = false;
|
|
92
|
+
this.timer = null;
|
|
93
|
+
this._intervalMs = (options && options.intervalMs) || WATCH_INTERVAL_MS;
|
|
94
|
+
this._lastRulesHash = null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Attempt to read, parse, and cryptographically verify the policy file.
|
|
99
|
+
* Returns { version, rules, signedAt } on success, or null on any failure.
|
|
100
|
+
* Fail-closed: unsigned, malformed, or tampered policies return null.
|
|
101
|
+
*/
|
|
102
|
+
_loadAndVerify() {
|
|
103
|
+
let content;
|
|
104
|
+
try {
|
|
105
|
+
content = fs.readFileSync(this.policyPath, 'utf8');
|
|
106
|
+
} catch (e) {
|
|
107
|
+
console.error('[PolicyWatcher] Cannot read policy file:', e.message);
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let policy;
|
|
112
|
+
try {
|
|
113
|
+
policy = JSON.parse(content);
|
|
114
|
+
} catch (e) {
|
|
115
|
+
console.error('[PolicyWatcher] Policy file is not valid JSON:', e.message);
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const { version, rules, signedAt, signature } = policy;
|
|
120
|
+
|
|
121
|
+
if (version !== 1 || !rules || typeof rules !== 'object' || !signedAt || !signature) {
|
|
122
|
+
console.error('[PolicyWatcher] Policy file is missing required fields (version, rules, signedAt, signature).');
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const payload = canonicalPayload(version, rules, signedAt);
|
|
127
|
+
let sigBuffer;
|
|
128
|
+
try {
|
|
129
|
+
sigBuffer = Buffer.from(signature, 'base64url');
|
|
130
|
+
} catch (e) {
|
|
131
|
+
console.error('[PolicyWatcher] Policy signature is not valid base64url.');
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let valid = false;
|
|
136
|
+
try {
|
|
137
|
+
valid = crypto.verify(null, payload, { key: PUBLIC_KEY_PEM, format: 'pem', type: 'spki' }, sigBuffer);
|
|
138
|
+
} catch (e) {
|
|
139
|
+
console.error('[PolicyWatcher] Signature verification error:', e.message);
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!valid) {
|
|
144
|
+
console.error('[PolicyWatcher] Policy signature is INVALID.');
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { version, rules, signedAt };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Verify the policy file cryptographically.
|
|
153
|
+
* Returns true if valid, false otherwise. Safe to call directly in tests.
|
|
154
|
+
*/
|
|
155
|
+
verify() {
|
|
156
|
+
return this._loadAndVerify() !== null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Hash the rules for change detection (not security-critical — just diffing).
|
|
161
|
+
*/
|
|
162
|
+
_hashRules(rules) {
|
|
163
|
+
return crypto.createHash('sha256').update(JSON.stringify(rules)).digest('hex');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Start the periodic integrity check.
|
|
168
|
+
* Verifies the policy on startup; calls onTamperDetected() if verification fails.
|
|
169
|
+
* Calls onValidChange(rules) with the initial rules on startup, then on every verified change.
|
|
170
|
+
*/
|
|
171
|
+
start() {
|
|
172
|
+
if (!fs.existsSync(this.policyPath)) return;
|
|
173
|
+
|
|
174
|
+
// F-02a: refuse to verify a policy file against the bundled dev key in production.
|
|
175
|
+
// The dev private key is public (committed to the repo), so any attacker can forge
|
|
176
|
+
// a valid signature. Only allow the dev key when FW_ALLOW_DEV_POLICY_KEY=1.
|
|
177
|
+
if (USING_DEV_POLICY_KEY && process.env.FW_ALLOW_DEV_POLICY_KEY !== '1') {
|
|
178
|
+
console.error(
|
|
179
|
+
'[CRITICAL] Policy file found but FW_POLICY_PUBKEY is missing or set to the bundled ' +
|
|
180
|
+
'development key. The matching private key is public, making this unsafe. Set ' +
|
|
181
|
+
'FW_POLICY_PUBKEY to your production public key, or set FW_ALLOW_DEV_POLICY_KEY=1 ' +
|
|
182
|
+
'for local/dev/CI use. Refusing to run.'
|
|
183
|
+
);
|
|
184
|
+
process.exit(1);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const initial = this._loadAndVerify();
|
|
188
|
+
if (!initial) {
|
|
189
|
+
this.locked = true;
|
|
190
|
+
console.error('\n[CRITICAL] Policy file failed signature verification on startup. EMERGENCY LOCKDOWN ACTIVE.');
|
|
191
|
+
this.onTamperDetected();
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
this._lastRulesHash = this._hashRules(initial.rules);
|
|
196
|
+
this.onValidChange(initial.rules);
|
|
197
|
+
|
|
198
|
+
this.timer = setInterval(() => {
|
|
199
|
+
if (this.locked) return;
|
|
200
|
+
|
|
201
|
+
const result = this._loadAndVerify();
|
|
202
|
+
if (!result) {
|
|
203
|
+
this.locked = true;
|
|
204
|
+
console.error('\n[CRITICAL] Policy integrity violation detected. EMERGENCY LOCKDOWN ACTIVE.');
|
|
205
|
+
this.onTamperDetected();
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const newHash = this._hashRules(result.rules);
|
|
210
|
+
if (newHash !== this._lastRulesHash) {
|
|
211
|
+
this._lastRulesHash = newHash;
|
|
212
|
+
console.log('[PolicyWatcher] Valid policy update detected \u2014 hot-reloading rules.');
|
|
213
|
+
this.onValidChange(result.rules);
|
|
214
|
+
}
|
|
215
|
+
}, this._intervalMs);
|
|
216
|
+
|
|
217
|
+
if (this.timer.unref) this.timer.unref();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
stop() {
|
|
221
|
+
if (this.timer) {
|
|
222
|
+
clearInterval(this.timer);
|
|
223
|
+
this.timer = null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
get isLocked() {
|
|
228
|
+
return this.locked;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
module.exports = { PolicyWatcher, canonicalPayload, assertProductionKeyConfig };
|
|
233
|
+
|
package/src/policy.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// packages/fw-agent/src/policy.js
|
|
2
|
+
const crypto = require('crypto');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Canonical Helios object structure for tamper-evident hashing
|
|
6
|
+
*/
|
|
7
|
+
function createCanonicalObject(data, objectType = 'security_policy') {
|
|
8
|
+
return {
|
|
9
|
+
category: objectType,
|
|
10
|
+
created_at: data.created_at || new Date().toISOString(),
|
|
11
|
+
key: data.key || 'active_policy',
|
|
12
|
+
relationships: data.relationships || [],
|
|
13
|
+
source: data.source || 'fw-control-plane',
|
|
14
|
+
value: data.value || data.rules || {}
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Hash a memory object using SHA-256 (Helios-compatible format)
|
|
20
|
+
*/
|
|
21
|
+
function hashMemoryObject(obj) {
|
|
22
|
+
// Canonical JSON serialization (sorted keys, no whitespace)
|
|
23
|
+
const canonical = JSON.stringify(obj, Object.keys(obj).sort());
|
|
24
|
+
return crypto.createHash('sha256').update(canonical).digest('hex');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Verify policy integrity against Helios hash
|
|
29
|
+
*/
|
|
30
|
+
async function verifyPolicyIntegrity(policyObject) {
|
|
31
|
+
if (!policyObject || !policyObject.rules) {
|
|
32
|
+
console.warn('[Policy Verification] Missing or empty policy object');
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Construct the canonical object as it was signed
|
|
37
|
+
const canonicalObject = createCanonicalObject(policyObject, 'security_policy');
|
|
38
|
+
|
|
39
|
+
// Calculate the hash locally
|
|
40
|
+
const calculatedHash = hashMemoryObject(canonicalObject);
|
|
41
|
+
|
|
42
|
+
// Verify against the provided hash
|
|
43
|
+
if (policyObject.helios_hash) {
|
|
44
|
+
if (calculatedHash !== policyObject.helios_hash) {
|
|
45
|
+
console.error('[CRITICAL] Policy tampering detected! Hash mismatch.');
|
|
46
|
+
console.error(`Expected: ${policyObject.helios_hash}`);
|
|
47
|
+
console.error(`Calculated: ${calculatedHash}`);
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
console.log('[Policy Verification] ✅ Policy integrity verified');
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// If no hash provided, log warning but allow (graceful degradation)
|
|
55
|
+
console.warn('[Policy Verification] ⚠️ No integrity hash provided (unsigned policy)');
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Create a forensic object for a security event
|
|
61
|
+
*/
|
|
62
|
+
function createForensicObject(eventType, packageName, operation, details) {
|
|
63
|
+
return {
|
|
64
|
+
category: 'quarantine_event',
|
|
65
|
+
created_at: new Date().toISOString(),
|
|
66
|
+
key: `ev_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
|
|
67
|
+
relationships: [packageName],
|
|
68
|
+
source: 'fw-agent-proxy',
|
|
69
|
+
value: {
|
|
70
|
+
eventType,
|
|
71
|
+
operation,
|
|
72
|
+
details,
|
|
73
|
+
timestamp: Date.now()
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = {
|
|
79
|
+
createCanonicalObject,
|
|
80
|
+
hashMemoryObject,
|
|
81
|
+
verifyPolicyIntegrity,
|
|
82
|
+
createForensicObject
|
|
83
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// packages/fw-agent/src/quarantine.js
|
|
2
|
+
const { hashMemoryObject, createForensicObject } = require('./policy');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* QuarantineStub - A Proxy that intercepts all method calls on quarantined modules
|
|
6
|
+
* Every intercept is hashed and logged for forensic analysis
|
|
7
|
+
*/
|
|
8
|
+
class QuarantineStub {
|
|
9
|
+
constructor(packageName, telemetry) {
|
|
10
|
+
this.packageName = packageName;
|
|
11
|
+
this.telemetry = telemetry;
|
|
12
|
+
this.interceptCount = 0;
|
|
13
|
+
this.rateLimitCount = 0;
|
|
14
|
+
this.initTime = BigInt(process.hrtime.bigint());
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Record a quarantine event with tamper-evident hashing
|
|
19
|
+
*/
|
|
20
|
+
record(operation, details = {}) {
|
|
21
|
+
this.interceptCount++;
|
|
22
|
+
|
|
23
|
+
// Detect rapid-fire intercepts (>100 calls in <1ms) as a potential exhaustion attack.
|
|
24
|
+
// Do NOT kill the host process — rate-limit logs and return to preserve availability.
|
|
25
|
+
const currentDelta = Number(process.hrtime.bigint() - this.initTime) / 1e6;
|
|
26
|
+
if (this.interceptCount > 100 && currentDelta < 1.0) {
|
|
27
|
+
this.rateLimitCount++;
|
|
28
|
+
if (this.rateLimitCount % 10 === 1) {
|
|
29
|
+
console.warn(
|
|
30
|
+
`[Quarantine] Rapid-fire intercepts on "${this.packageName}" ` +
|
|
31
|
+
`(${this.interceptCount} calls in ${currentDelta.toFixed(3)}ms). ` +
|
|
32
|
+
`Rate-limiting (suppressed ${this.rateLimitCount - 1} events).`
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
return; // Inert return — preserve host availability
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Create the forensic object
|
|
39
|
+
const forensicObject = createForensicObject(
|
|
40
|
+
'QUARANTINE_BREACH',
|
|
41
|
+
this.packageName,
|
|
42
|
+
operation,
|
|
43
|
+
{
|
|
44
|
+
...details,
|
|
45
|
+
interceptCount: this.interceptCount
|
|
46
|
+
}
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// Calculate the forensic hash (SHA-256 of canonical JSON)
|
|
50
|
+
const eventHash = hashMemoryObject(forensicObject);
|
|
51
|
+
|
|
52
|
+
// Only emit telemetry if it exists (may be disabled during benchmarks)
|
|
53
|
+
if (this.telemetry && this.telemetry.emit) {
|
|
54
|
+
// Emit telemetry with the hash for immutable audit trail
|
|
55
|
+
this.telemetry.emit('quarantine_event', {
|
|
56
|
+
...forensicObject,
|
|
57
|
+
hash: eventHash // Tamper-evident anchor
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Also log to console for real-time observability (only first breach)
|
|
62
|
+
if (this.interceptCount === 1) {
|
|
63
|
+
console.warn(
|
|
64
|
+
`[Quarantine Intercept] Package: ${this.packageName} | Operation: ${operation} | Hash: ${eventHash.substring(0, 16)}...`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Create a Proxy that intercepts all property accesses and method calls
|
|
71
|
+
*/
|
|
72
|
+
createProxy() {
|
|
73
|
+
return new Proxy({}, {
|
|
74
|
+
get: (target, prop) => {
|
|
75
|
+
// F-17: Prevent the proxy from being treated as a thenable/iterable.
|
|
76
|
+
// If `then`, Symbol.toPrimitive, or Symbol.iterator resolve to a function,
|
|
77
|
+
// Promise.resolve() / await / for..of will hang or throw unexpectedly.
|
|
78
|
+
if (prop === 'then' || prop === Symbol.toPrimitive || prop === Symbol.iterator) {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Record the interception
|
|
83
|
+
this.record(`property_access`, { property: String(prop) });
|
|
84
|
+
|
|
85
|
+
// Return a function that logs further calls
|
|
86
|
+
return (...args) => {
|
|
87
|
+
this.record(`method_call`, {
|
|
88
|
+
property: String(prop),
|
|
89
|
+
args: args.length
|
|
90
|
+
});
|
|
91
|
+
return null; // Graceful degradation
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
set: (target, prop, value) => {
|
|
96
|
+
this.record(`property_write`, { property: String(prop) });
|
|
97
|
+
return true; // Pretend success
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
has: (target, prop) => {
|
|
101
|
+
this.record(`property_check`, { property: String(prop) });
|
|
102
|
+
return false; // Pretend property doesn't exist
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
deleteProperty: (target, prop) => {
|
|
106
|
+
this.record(`property_delete`, { property: String(prop) });
|
|
107
|
+
return true; // Pretend deletion succeeded
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
ownKeys: (target) => {
|
|
111
|
+
this.record(`enumerate_keys`, {});
|
|
112
|
+
return [];
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
getOwnPropertyDescriptor: (target, prop) => {
|
|
116
|
+
this.record(`descriptor_query`, { property: String(prop) });
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = { QuarantineStub };
|