argusqa-os 9.9.0 → 10.0.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/README.md +524 -419
- package/glama.json +1 -1
- package/package.json +10 -3
- package/src/mcp-server.js +25 -12
- package/src/orchestration/report-processor.js +45 -2
- package/src/utils/governance-seam.js +237 -0
- package/src/utils/redaction-policy.js +278 -0
- package/src/utils/secret-patterns.js +444 -428
- package/src/utils/sensitivity-classifier.js +563 -498
- package/src/utils/team-vault.js +166 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aegis — structured redaction policy resolver (the engine `policy` param).
|
|
3
|
+
*
|
|
4
|
+
* The shipped engine enforces exactly two knobs: the redaction `mode`
|
|
5
|
+
* (ARGUS_REDACT_MODE) and the always-on fail-closed floor. An org, however, wants
|
|
6
|
+
* finer control — which of the 13 secret / 7 PII detectors run, extra custom
|
|
7
|
+
* patterns, which finding TYPES count as sensitive, and a narrower egress field
|
|
8
|
+
* allowlist. This module turns a structured policy object (the same shape the
|
|
9
|
+
* closed control plane stores + serves) into an "effective config" the existing
|
|
10
|
+
* redaction entry points consume via their `opts`.
|
|
11
|
+
*
|
|
12
|
+
* SECURITY POSTURE — this module UPHOLDS Aegis's fail-closed inversion:
|
|
13
|
+
* • A missing / malformed / unparseable policy resolves to STRICT_DEFAULT — the
|
|
14
|
+
* strictest currently-shipped behavior (all rules on, built-in sensitive
|
|
15
|
+
* types + allowlist). Never looser on error.
|
|
16
|
+
* • The egress field allowlist may only ever be NARROWED (intersected with the
|
|
17
|
+
* built-in) — a policy can never add a new field that leaks (that intersection
|
|
18
|
+
* is applied in sensitivity-classifier.js, where the built-in list lives).
|
|
19
|
+
* • The sensitive-type set is only ever WIDENED (unioned with the built-in) — a
|
|
20
|
+
* policy can add types but never drop a built-in one.
|
|
21
|
+
* • The statistical-entropy layer and the finding-level catch-alls (body fields,
|
|
22
|
+
* security category) are NOT policy-toggleable — they are the floor that keeps
|
|
23
|
+
* an actual secret from leaking even if a named rule is turned off.
|
|
24
|
+
* • No policy at all (the default) ⇒ every effect is null ⇒ byte-identical to the
|
|
25
|
+
* pre-policy engine. The param is opt-in and inert until a policy is supplied.
|
|
26
|
+
*
|
|
27
|
+
* Pure + zero-dependency + zero-I/O (same discipline as secret-patterns.js). It
|
|
28
|
+
* imports nothing from the engine so it cannot introduce a circular dependency;
|
|
29
|
+
* the allowlist intersection / type union that need the built-in lists happen at
|
|
30
|
+
* the classifier call sites.
|
|
31
|
+
*
|
|
32
|
+
* NOTE: the `person_name_ner` toggle in the policy schema references a detector
|
|
33
|
+
* the OSS engine does not ship (there is no NER). Unknown rule names in a policy
|
|
34
|
+
* are simply ignored — they disable nothing real and can never loosen the floor.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** The five redaction modes maskValue() understands. */
|
|
38
|
+
export const VALID_MODES = new Set(['mask', 'label', 'hash', 'token', 'drop']);
|
|
39
|
+
|
|
40
|
+
/** Hard cap on operator-supplied lists so a pathological policy can't DoS a run. */
|
|
41
|
+
const MAX_EXTRA_RULES = 50;
|
|
42
|
+
const MAX_LIST = 256;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The null-effect resolution: a run with STRICT_DEFAULT behaves EXACTLY as the
|
|
46
|
+
* pre-policy engine (all rules on, caller's env/opts mode, built-in sensitive
|
|
47
|
+
* types + egress allowlist). This is both the "no policy supplied" result and the
|
|
48
|
+
* fail-closed fallback on any error — identical strict behavior either way.
|
|
49
|
+
*/
|
|
50
|
+
export const STRICT_DEFAULT = Object.freeze({
|
|
51
|
+
version: null,
|
|
52
|
+
orgId: null,
|
|
53
|
+
enforcement: 'strict',
|
|
54
|
+
mode: null, // null ⇒ caller's env/opts mode (byte-identical default)
|
|
55
|
+
ruleFilter: null, // null ⇒ ALL secret + PII rules run
|
|
56
|
+
extraSecretRules: Object.freeze([]),
|
|
57
|
+
sensitiveTypes: null, // null ⇒ built-in SENSITIVE_TYPES only
|
|
58
|
+
egressAllowlist: null, // null ⇒ built-in EGRESS_ALLOWLIST only
|
|
59
|
+
sinks: null,
|
|
60
|
+
floor: Object.freeze({ failOpen: false, minMode: 'mask' }),
|
|
61
|
+
invalid: false,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/** Fail-closed clone (marks `invalid` so a caller can log/telemeter the reject). */
|
|
65
|
+
function failClosed() {
|
|
66
|
+
return { ...STRICT_DEFAULT, floor: { ...STRICT_DEFAULT.floor }, extraSecretRules: [], invalid: true };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Compile the org's custom `rules.secret.extra` entries into `{name, regex}`
|
|
71
|
+
* secret rules (ADDITIVE — they only ever redact MORE). Each entry may be a bare
|
|
72
|
+
* regex-source string or `{ name?, pattern|source|regex, flags? }`. A bad regex is
|
|
73
|
+
* skipped (logged nowhere — this module is I/O-free — never thrown). The global
|
|
74
|
+
* flag is forced so findSensitiveSpans' matchAll iterates correctly.
|
|
75
|
+
* @param {unknown} extra
|
|
76
|
+
* @returns {{name:string, regex:RegExp}[]}
|
|
77
|
+
*/
|
|
78
|
+
function compileExtraSecretRules(extra) {
|
|
79
|
+
if (!Array.isArray(extra)) return [];
|
|
80
|
+
const out = [];
|
|
81
|
+
for (const item of extra.slice(0, MAX_EXTRA_RULES)) {
|
|
82
|
+
let source;
|
|
83
|
+
let flags = '';
|
|
84
|
+
let name = 'policy_custom';
|
|
85
|
+
try {
|
|
86
|
+
if (typeof item === 'string') {
|
|
87
|
+
source = item;
|
|
88
|
+
} else if (item && typeof item === 'object') {
|
|
89
|
+
source = item.pattern ?? item.source ?? item.regex;
|
|
90
|
+
if (typeof item.flags === 'string') flags = item.flags;
|
|
91
|
+
if (typeof item.name === 'string' && item.name) name = item.name;
|
|
92
|
+
}
|
|
93
|
+
if (typeof source !== 'string' || source === '') continue;
|
|
94
|
+
// Force /g; drop a caller-supplied duplicate g to avoid "invalid flags".
|
|
95
|
+
const merged = 'g' + flags.replace(/g/g, '');
|
|
96
|
+
out.push({ name, regex: new RegExp(source, merged) });
|
|
97
|
+
} catch {
|
|
98
|
+
continue; // unparseable regex ⇒ skip (fail toward the built-in floor)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Build the (name, kind) → boolean rule predicate from `policy.rules`.
|
|
106
|
+
* • secret rules are all-or-nothing via `rules.secret.all` (default true).
|
|
107
|
+
* • each PII rule is on unless `rules.pii[name] === false` (default true).
|
|
108
|
+
* • entropy / any other kind is ALWAYS on (the non-toggleable floor).
|
|
109
|
+
* Returns null when `policy.rules` is absent (⇒ every rule runs, byte-identical).
|
|
110
|
+
* @param {unknown} rules
|
|
111
|
+
* @returns {((name:string, kind:string) => boolean) | null}
|
|
112
|
+
*/
|
|
113
|
+
function buildRuleFilter(rules) {
|
|
114
|
+
if (!rules || typeof rules !== 'object') return null;
|
|
115
|
+
const secretAll = rules.secret && typeof rules.secret === 'object'
|
|
116
|
+
? rules.secret.all !== false
|
|
117
|
+
: true;
|
|
118
|
+
const piiMap = rules.pii && typeof rules.pii === 'object' ? rules.pii : null;
|
|
119
|
+
return (name, kind) => {
|
|
120
|
+
if (kind === 'secret') return secretAll;
|
|
121
|
+
if (kind === 'pii') return piiMap ? piiMap[name] !== false : true;
|
|
122
|
+
return true; // entropy + future kinds: not policy-toggleable (floor)
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Resolve a raw org policy (the §4.2 schema) into an effective config. ALWAYS
|
|
128
|
+
* fail-closed: any missing / non-object / throwing input yields STRICT_DEFAULT.
|
|
129
|
+
* A valid policy may narrow the rule set + widen the sensitive-type set + narrow
|
|
130
|
+
* the egress allowlist + set the mode; it can never disable the entropy floor,
|
|
131
|
+
* fail open, or widen the allowlist (those are enforced here + at the call sites).
|
|
132
|
+
*
|
|
133
|
+
* @param {unknown} policy
|
|
134
|
+
* @returns {typeof STRICT_DEFAULT}
|
|
135
|
+
*/
|
|
136
|
+
export function resolvePolicy(policy) {
|
|
137
|
+
if (policy === null || policy === undefined) return STRICT_DEFAULT;
|
|
138
|
+
if (typeof policy !== 'object') return failClosed();
|
|
139
|
+
try {
|
|
140
|
+
const mode = typeof policy.mode === 'string' && VALID_MODES.has(policy.mode) ? policy.mode : null;
|
|
141
|
+
|
|
142
|
+
const rawTypes = Array.isArray(policy.sensitiveTypes)
|
|
143
|
+
? policy.sensitiveTypes.filter((t) => typeof t === 'string' && t).slice(0, MAX_LIST)
|
|
144
|
+
: null;
|
|
145
|
+
const sensitiveTypes = rawTypes && rawTypes.length ? rawTypes : null;
|
|
146
|
+
|
|
147
|
+
const rawAllow = Array.isArray(policy.egressFieldAllowlist)
|
|
148
|
+
? policy.egressFieldAllowlist.filter((f) => typeof f === 'string' && f).slice(0, MAX_LIST)
|
|
149
|
+
: null;
|
|
150
|
+
// An empty-but-present allowlist is meaningful (drop every optional field) —
|
|
151
|
+
// keep [] distinct from null. The intersection with the built-in happens in
|
|
152
|
+
// the classifier, so this can only ever NARROW.
|
|
153
|
+
const egressAllowlist = rawAllow !== null ? rawAllow : null;
|
|
154
|
+
|
|
155
|
+
const minMode = policy.floor && VALID_MODES.has(policy.floor.minMode) ? policy.floor.minMode : 'mask';
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
version: typeof policy.version === 'number' ? policy.version : null,
|
|
159
|
+
orgId: typeof policy.orgId === 'string' ? policy.orgId : null,
|
|
160
|
+
enforcement: policy.enforcement === 'advisory' ? 'advisory' : 'strict',
|
|
161
|
+
mode,
|
|
162
|
+
ruleFilter: buildRuleFilter(policy.rules),
|
|
163
|
+
extraSecretRules: compileExtraSecretRules(policy.rules && policy.rules.secret && policy.rules.secret.extra),
|
|
164
|
+
sensitiveTypes,
|
|
165
|
+
egressAllowlist,
|
|
166
|
+
sinks: policy.sinks && typeof policy.sinks === 'object' ? { ...policy.sinks } : null,
|
|
167
|
+
floor: { failOpen: false, minMode }, // failOpen is FORCED false — a policy can never open the gate
|
|
168
|
+
invalid: false,
|
|
169
|
+
};
|
|
170
|
+
} catch {
|
|
171
|
+
return failClosed(); // a throwing getter / exotic input ⇒ strict floor
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Project a resolved config into the `opts` fields the redaction entry points
|
|
177
|
+
* consume. Absent effects are omitted (never `undefined`-override an explicit
|
|
178
|
+
* caller opt). Returns null for the null-effect / no-policy case so callers can
|
|
179
|
+
* cheaply short-circuit to their existing behavior.
|
|
180
|
+
* @param {typeof STRICT_DEFAULT | null} resolved
|
|
181
|
+
* @returns {{mode?:string, ruleFilter?:Function, extraSecretRules?:object[], sensitiveTypes?:string[], egressAllowlist?:string[]} | null}
|
|
182
|
+
*/
|
|
183
|
+
export function policyOpts(resolved) {
|
|
184
|
+
if (!resolved || typeof resolved !== 'object') return null;
|
|
185
|
+
const out = {};
|
|
186
|
+
if (resolved.mode) out.mode = resolved.mode;
|
|
187
|
+
if (resolved.ruleFilter) out.ruleFilter = resolved.ruleFilter;
|
|
188
|
+
if (Array.isArray(resolved.extraSecretRules) && resolved.extraSecretRules.length) out.extraSecretRules = resolved.extraSecretRules;
|
|
189
|
+
if (Array.isArray(resolved.sensitiveTypes) && resolved.sensitiveTypes.length) out.sensitiveTypes = resolved.sensitiveTypes;
|
|
190
|
+
if (Array.isArray(resolved.egressAllowlist)) out.egressAllowlist = resolved.egressAllowlist; // [] is meaningful (narrow-all)
|
|
191
|
+
return Object.keys(out).length ? out : null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Parse ARGUS_REDACT_POLICY (inline JSON) at most once per distinct value.
|
|
195
|
+
let _envCache = { raw: null, resolved: null };
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The local/CI env entry point: read `ARGUS_REDACT_POLICY` (inline JSON) and
|
|
199
|
+
* resolve it. Returns null when unset (⇒ the byte-identical no-policy path).
|
|
200
|
+
* A SET-but-malformed value fails closed to the strict floor (the operator asked
|
|
201
|
+
* for governance; we honor it as "strictest" rather than silently ignoring it).
|
|
202
|
+
* Cached by raw string so hot egress paths don't re-parse.
|
|
203
|
+
* @returns {typeof STRICT_DEFAULT | null}
|
|
204
|
+
*/
|
|
205
|
+
export function policyFromEnv() {
|
|
206
|
+
const raw = process.env.ARGUS_REDACT_POLICY;
|
|
207
|
+
if (!raw) return null; // no policy ⇒ caller keeps its existing behavior
|
|
208
|
+
if (_envCache.raw === raw) return _envCache.resolved;
|
|
209
|
+
let resolved;
|
|
210
|
+
try {
|
|
211
|
+
resolved = resolvePolicy(JSON.parse(raw));
|
|
212
|
+
} catch {
|
|
213
|
+
resolved = failClosed(); // malformed JSON ⇒ strict floor, never looser
|
|
214
|
+
}
|
|
215
|
+
_envCache = { raw, resolved };
|
|
216
|
+
return resolved;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── Governance seam (the self-hosted central-policy bridge, AEGIS_FOR_TEAMS §9) ──
|
|
220
|
+
// This module stays PURE + I/O-free. The opt-in network seam (governance-seam.js)
|
|
221
|
+
// does the fetch + Ed25519 verify and injects a SYNC source here — the same
|
|
222
|
+
// discipline as secret-patterns.js setVaultTokenizer. The injected source returns
|
|
223
|
+
// a RESOLVED config: null when governance is inactive (no gov token ⇒ byte-
|
|
224
|
+
// identical), the org's resolved policy when a fresh verified one is in force, or
|
|
225
|
+
// STRICT_DEFAULT when active-but-cold/failed (fail-closed to the strict floor).
|
|
226
|
+
let _governanceSource = null;
|
|
227
|
+
|
|
228
|
+
/** Inject the governance policy source (governance-seam.js). null clears it. */
|
|
229
|
+
export function setGovernanceSource(fn) {
|
|
230
|
+
_governanceSource = typeof fn === 'function' ? fn : null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Read the injected source without ever throwing. */
|
|
234
|
+
function readGovernance() {
|
|
235
|
+
if (!_governanceSource) return null;
|
|
236
|
+
try {
|
|
237
|
+
return _governanceSource();
|
|
238
|
+
} catch {
|
|
239
|
+
// A throwing source ⇒ treat as active-but-failed → strict floor (fail-closed).
|
|
240
|
+
return STRICT_DEFAULT;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* True iff a governance policy is in force (a gov token is provisioned). The
|
|
246
|
+
* classifier ANDs this into the ARGUS_REDACT_SENSITIVE=0 opt-out so a dev cannot
|
|
247
|
+
* disable redaction locally while under an org policy (AEGIS_FOR_TEAMS §4.3). Pure:
|
|
248
|
+
* false whenever no source is wired (⇒ the opt-out keeps its byte-identical effect).
|
|
249
|
+
* @returns {boolean}
|
|
250
|
+
*/
|
|
251
|
+
export function governanceOptOutClamped() {
|
|
252
|
+
return readGovernance() != null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The single helper the redaction entry points call: resolve the effective policy
|
|
257
|
+
* from `opts.policy` (a RAW §4.2 policy object) if present, else the governance
|
|
258
|
+
* seam (network-fetched + verified), else the ARGUS_REDACT_POLICY env, then project
|
|
259
|
+
* to consumable opts. Returns null when no policy is in force anywhere (⇒ byte-
|
|
260
|
+
* identical default). Precedence: explicit opts.policy > governance > env.
|
|
261
|
+
* @param {{policy?: unknown}} [opts]
|
|
262
|
+
* @returns {{mode?:string, ruleFilter?:Function, extraSecretRules?:object[], sensitiveTypes?:string[], egressAllowlist?:string[]} | null}
|
|
263
|
+
*/
|
|
264
|
+
export function effectivePolicyOpts(opts = {}) {
|
|
265
|
+
let resolved;
|
|
266
|
+
if (opts.policy !== undefined && opts.policy !== null) {
|
|
267
|
+
resolved = resolvePolicy(opts.policy);
|
|
268
|
+
} else {
|
|
269
|
+
const gov = readGovernance();
|
|
270
|
+
resolved = gov != null ? gov : policyFromEnv();
|
|
271
|
+
}
|
|
272
|
+
return policyOpts(resolved);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Test seam: drop the ARGUS_REDACT_POLICY parse cache. */
|
|
276
|
+
export function _resetPolicyCacheForTests() {
|
|
277
|
+
_envCache = { raw: null, resolved: null };
|
|
278
|
+
}
|