argusqa-os 9.8.1 → 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 -395
- package/glama.json +2 -2
- package/package.json +12 -3
- package/scripts/rehydrate-report.mjs +62 -0
- package/src/cli/pr-validate.js +40 -5
- package/src/mcp-server.js +155 -28
- package/src/orchestration/dispatcher.js +28 -0
- package/src/orchestration/report-processor.js +65 -1
- package/src/utils/aegis-vault.js +251 -0
- package/src/utils/github-reporter.js +33 -4
- package/src/utils/governance-seam.js +237 -0
- package/src/utils/html-reporter.js +26 -1
- package/src/utils/redaction-policy.js +278 -0
- package/src/utils/secret-patterns.js +444 -0
- package/src/utils/sensitivity-classifier.js +563 -0
- package/src/utils/team-vault.js +166 -0
|
@@ -15,6 +15,9 @@ import { applyOverrides } from
|
|
|
15
15
|
import { loadBaseline, saveBaseline, applyBaseline, appendTrend, getCurrentBranch } from '../utils/baseline-manager.js';
|
|
16
16
|
import { loadRunHistory, recordRunHistory, applyNoiseFilter } from '../utils/noise-filter.js';
|
|
17
17
|
import { getRecentChanges, linkRootCauses } from '../utils/root-cause-linker.js';
|
|
18
|
+
import { classifySensitivity, summarizeRedaction } from '../utils/sensitivity-classifier.js';
|
|
19
|
+
import { effectivePolicyOpts, governanceOptOutClamped } from '../utils/redaction-policy.js';
|
|
20
|
+
import { ensureGovernancePolicy, postRedactionAggregate, governanceActive } from '../utils/governance-seam.js';
|
|
18
21
|
|
|
19
22
|
const logger = childLogger('report-processor');
|
|
20
23
|
|
|
@@ -66,6 +69,24 @@ export function rebuildSummary(report) {
|
|
|
66
69
|
}
|
|
67
70
|
}
|
|
68
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Flatten every finding in a report (route errors, flow findings, codebase) into
|
|
74
|
+
* one array — used to compute the secret-free governance aggregate. Read-only.
|
|
75
|
+
* @param {object} report
|
|
76
|
+
* @returns {object[]}
|
|
77
|
+
*/
|
|
78
|
+
export function collectAllFindings(report) {
|
|
79
|
+
const out = [];
|
|
80
|
+
for (const routeResult of (report.routes ?? [])) {
|
|
81
|
+
for (const err of (routeResult.errors ?? [])) out.push(err);
|
|
82
|
+
}
|
|
83
|
+
for (const flowResult of (report.flows ?? [])) {
|
|
84
|
+
for (const finding of (flowResult.findings ?? [])) out.push(finding);
|
|
85
|
+
}
|
|
86
|
+
for (const finding of (report.codebase ?? [])) out.push(finding);
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
69
90
|
// ── Main Post-Crawl Processor ─────────────────────────────────────────────────
|
|
70
91
|
|
|
71
92
|
/**
|
|
@@ -78,6 +99,13 @@ export function rebuildSummary(report) {
|
|
|
78
99
|
* @returns {{ reportPath: string, diff: object }}
|
|
79
100
|
*/
|
|
80
101
|
export async function processReport(report, { outputDir, severityOverrides }) {
|
|
102
|
+
// 0. Aegis governance seam (opt-in, AEGIS_FOR_TEAMS §9): if a gov token is set,
|
|
103
|
+
// fetch + Ed25519-verify the org's central policy BEFORE any classification so
|
|
104
|
+
// every downstream redaction enforces it. Best-effort + fail-closed internally
|
|
105
|
+
// (a fetch/verify error leaves the engine on its strict floor). No token ⇒ no
|
|
106
|
+
// network, byte-identical.
|
|
107
|
+
await ensureGovernancePolicy();
|
|
108
|
+
|
|
81
109
|
// 1. Apply severity overrides (suppress or reclassify findings)
|
|
82
110
|
const { overriddenCount, suppressedCount } = applyOverrides(report, severityOverrides);
|
|
83
111
|
if (overriddenCount > 0 || suppressedCount > 0) {
|
|
@@ -129,9 +157,45 @@ export async function processReport(report, { outputDir, severityOverrides }) {
|
|
|
129
157
|
}
|
|
130
158
|
}
|
|
131
159
|
|
|
132
|
-
//
|
|
160
|
+
// reportPath is computed here (ahead of the write) so step 3c can point each
|
|
161
|
+
// finding's localRef at the on-disk report file it will be written to.
|
|
133
162
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
134
163
|
const reportPath = path.join(outputDir, `error-report-${timestamp}.json`);
|
|
164
|
+
|
|
165
|
+
// 3c. Aegis sensitivity classification — tag findings for the egress guards
|
|
166
|
+
// (REDACTION_BOUNDARY_MAX_PLAN.md §6 Step 3). Tagging happens on shallow
|
|
167
|
+
// CLONES, so the LOCAL report below keeps 100% fidelity — Aegis only ever
|
|
168
|
+
// removes detail at an external sink, never on disk.
|
|
169
|
+
// SECURITY-CRITICAL: this is the ONE post-processor that fails CLOSED. On
|
|
170
|
+
// any error the egress guards must redact everything, so we set the
|
|
171
|
+
// _aegisFailClosed flag rather than swallowing-and-continuing.
|
|
172
|
+
// A gov token clamps the ARGUS_REDACT_SENSITIVE=0 opt-out (§4.3) so tagging
|
|
173
|
+
// still runs under an org policy — a dev can't disable it locally.
|
|
174
|
+
if (process.env.ARGUS_REDACT_SENSITIVE !== '0' || governanceOptOutClamped()) {
|
|
175
|
+
try {
|
|
176
|
+
const { sensitiveCount } = classifySensitivity(report, { reportRef: path.basename(reportPath) });
|
|
177
|
+
if (sensitiveCount > 0) logger.info(`[ARGUS] Aegis: ${sensitiveCount} finding(s) tagged sensitive`);
|
|
178
|
+
} catch (err) {
|
|
179
|
+
logger.error(`[ARGUS] Aegis classify failed — egress guards will fail closed: ${err.message}`);
|
|
180
|
+
report._aegisFailClosed = true; // sinks read this and redact everything
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 3d. Governance audit trail (opt-in): post a SECRET-FREE redaction aggregate
|
|
184
|
+
// (byReason label counts only) to the org's audit sink. Best-effort — never
|
|
185
|
+
// blocks or fails the run, never sends a secret. No-op unless a gov token +
|
|
186
|
+
// ARGUS_REDACT_AUDIT_URL are set.
|
|
187
|
+
if (governanceActive() && process.env.ARGUS_REDACT_AUDIT_URL) {
|
|
188
|
+
try {
|
|
189
|
+
const all = collectAllFindings(report);
|
|
190
|
+
const summary = summarizeRedaction(all, effectivePolicyOpts({}));
|
|
191
|
+
await postRedactionAggregate(summary, { meta: { runRef: path.basename(reportPath) } });
|
|
192
|
+
} catch (err) {
|
|
193
|
+
logger.debug(`[ARGUS] Aegis governance aggregate skipped: ${err.message}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 4. Write JSON report
|
|
135
199
|
try {
|
|
136
200
|
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2)); // lgtm[js/network-data-to-file] — intentional: Argus persists crawl findings to a local JSON report file by design
|
|
137
201
|
} catch (err) {
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aegis — local re-hydration vault (Step 8 of REDACTION_BOUNDARY_MAX_PLAN.md).
|
|
3
|
+
*
|
|
4
|
+
* OPTIONAL, default OFF (ARGUS_REDACT_VAULT=1 to enable). Implements the enterprise
|
|
5
|
+
* "smart redaction" reversible-tokenization pattern (plan §5.3): a sensitive span is
|
|
6
|
+
* replaced on egress (scrubText's `token` mode) by a deterministic, information-free
|
|
7
|
+
* token (`AEGIS_<hmac16>`), and the token→original mapping is written to a LOCAL,
|
|
8
|
+
* gitignored, 0600 vault file. The token that crosses the trust boundary reveals
|
|
9
|
+
* NOTHING; only an operator on THIS machine — who holds the per-machine key and the
|
|
10
|
+
* vault files — can re-hydrate via `npm run report:rehydrate`.
|
|
11
|
+
*
|
|
12
|
+
* SECURITY POSTURE — fail CLOSED (consistent with the rest of Aegis):
|
|
13
|
+
* - The token is an HMAC tag → it cannot contain the secret. A leak of the egress
|
|
14
|
+
* artifact discloses no plaintext.
|
|
15
|
+
* - If the vault is NOT enabled / wired, scrubText's `token` mode falls back to
|
|
16
|
+
* MASK (never the raw value) — see secret-patterns.js maskValue.
|
|
17
|
+
* - The vault key + mapping files are written 0600 and live under
|
|
18
|
+
* reports/.aegis-vault/ (gitignored). The audit trail is secret-free (token only).
|
|
19
|
+
*
|
|
20
|
+
* Crypto note (plan §5.3 / §13.6): the default token is HMAC-SHA256 — NOT
|
|
21
|
+
* format-preserving, so it needs no cipher. If a shape-preserving token is ever
|
|
22
|
+
* required, use NIST FF1 (SP 800-38G); FF3/FF3-1 were WITHDRAWN by NIST in Feb 2025
|
|
23
|
+
* and must not be used.
|
|
24
|
+
*
|
|
25
|
+
* This module is the ONLY place Aegis does filesystem I/O. secret-patterns.js stays
|
|
26
|
+
* pure — it receives `tokenFor` through the setVaultTokenizer injection seam.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import fs from 'fs';
|
|
30
|
+
import path from 'path';
|
|
31
|
+
import crypto from 'crypto';
|
|
32
|
+
import { childLogger } from './logger.js';
|
|
33
|
+
import { getCurrentBranch } from './baseline-manager.js';
|
|
34
|
+
import { setVaultTokenizer } from './secret-patterns.js';
|
|
35
|
+
|
|
36
|
+
const logger = childLogger('aegis-vault');
|
|
37
|
+
|
|
38
|
+
const TOKEN_PREFIX = 'AEGIS_';
|
|
39
|
+
const TOKEN_HEX_LEN = 16; // 64-bit HMAC tag
|
|
40
|
+
// Matches exactly what computeToken emits — used by rehydrate to find tokens.
|
|
41
|
+
const TOKEN_RE = /AEGIS_[0-9a-f]{16}/g;
|
|
42
|
+
|
|
43
|
+
let _key = null; // cached per-machine key (Buffer)
|
|
44
|
+
let _branch = null; // cached branch name (avoid repeated git calls)
|
|
45
|
+
let _wired = false; // setVaultTokenizer registered?
|
|
46
|
+
const _seen = new Set(); // tokens persisted this process (in-run dedup)
|
|
47
|
+
|
|
48
|
+
/** True when the operator opted into the reversible vault. */
|
|
49
|
+
export function vaultEnabled() {
|
|
50
|
+
return process.env.ARGUS_REDACT_VAULT === '1';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Base vault dir (overridable for tests/operators via ARGUS_REDACT_VAULT_DIR). */
|
|
54
|
+
function vaultDir() {
|
|
55
|
+
return process.env.ARGUS_REDACT_VAULT_DIR || path.join('reports', '.aegis-vault');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Secret-free provenance/audit dir (sibling of the vault). */
|
|
59
|
+
function auditDir() {
|
|
60
|
+
return process.env.ARGUS_REDACT_AUDIT_DIR || path.join('reports', '.aegis-audit');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function keyFile() {
|
|
64
|
+
return path.join(vaultDir(), 'key');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function ensureDir(dir) {
|
|
68
|
+
try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch { /* best-effort */ }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Per-machine HMAC key. Read from reports/.aegis-vault/key if present, else
|
|
73
|
+
* generated (32 random bytes) and persisted 0600. Cached in-process. On a write
|
|
74
|
+
* failure an ephemeral key is used (tokens stay valid within the run).
|
|
75
|
+
* @returns {Buffer}
|
|
76
|
+
*/
|
|
77
|
+
export function getKey() {
|
|
78
|
+
if (_key) return _key;
|
|
79
|
+
const file = keyFile();
|
|
80
|
+
try {
|
|
81
|
+
if (fs.existsSync(file)) {
|
|
82
|
+
const hex = fs.readFileSync(file, 'utf8').trim();
|
|
83
|
+
if (/^[0-9a-f]{64}$/i.test(hex)) { _key = Buffer.from(hex, 'hex'); return _key; }
|
|
84
|
+
}
|
|
85
|
+
} catch { /* fall through and regenerate */ }
|
|
86
|
+
|
|
87
|
+
ensureDir(vaultDir());
|
|
88
|
+
const key = crypto.randomBytes(32);
|
|
89
|
+
try {
|
|
90
|
+
// Owner-only (0600) — the key seeds every reversible token. chmod after write is
|
|
91
|
+
// best-effort (no-op on Windows, where access is ACL-based, not POSIX mode bits).
|
|
92
|
+
fs.writeFileSync(file, key.toString('hex'), { encoding: 'utf8', mode: 0o600 });
|
|
93
|
+
try { fs.chmodSync(file, 0o600); } catch { /* mode bits unsupported on this platform */ }
|
|
94
|
+
} catch (err) {
|
|
95
|
+
logger.warn(`[ARGUS] Aegis vault: could not persist key — using an ephemeral key: ${err.message}`);
|
|
96
|
+
}
|
|
97
|
+
_key = key;
|
|
98
|
+
return _key;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function currentBranch() {
|
|
102
|
+
if (_branch) return _branch;
|
|
103
|
+
try { _branch = getCurrentBranch() || 'default'; } catch { _branch = 'default'; }
|
|
104
|
+
return _branch;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function vaultFile() {
|
|
108
|
+
return path.join(vaultDir(), `${currentBranch()}.jsonl`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Deterministic, information-free token for a value (HMAC-SHA256 tag). Same value ⇒
|
|
113
|
+
* same token on this machine, so the external artifact stays diff-stable. Does NOT
|
|
114
|
+
* persist a mapping — use tokenFor for that.
|
|
115
|
+
* @param {*} value
|
|
116
|
+
* @returns {string}
|
|
117
|
+
*/
|
|
118
|
+
export function computeToken(value) {
|
|
119
|
+
const v = String(value ?? '');
|
|
120
|
+
const tag = crypto.createHmac('sha256', getKey()).update(v).digest('hex').slice(0, TOKEN_HEX_LEN);
|
|
121
|
+
return TOKEN_PREFIX + tag;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Append a secret-free provenance line for an egress action. The audit trail
|
|
126
|
+
* records WHAT token was minted, never the plaintext (the token is information-free).
|
|
127
|
+
* Gives the NIST-RMF "traceability" trail without itself storing a secret.
|
|
128
|
+
* @param {object} entry
|
|
129
|
+
*/
|
|
130
|
+
export function appendAudit(entry) {
|
|
131
|
+
ensureDir(auditDir());
|
|
132
|
+
const file = path.join(auditDir(), `${currentBranch()}.jsonl`);
|
|
133
|
+
const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n';
|
|
134
|
+
try {
|
|
135
|
+
fs.appendFileSync(file, line, { encoding: 'utf8', mode: 0o600 });
|
|
136
|
+
try { fs.chmodSync(file, 0o600); } catch { /* unsupported on Windows */ }
|
|
137
|
+
} catch { /* audit is best-effort; never break egress */ }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Persist a token→value mapping to the vault file (0600). In-run dedup keeps the
|
|
142
|
+
* file from growing per duplicate span within a single process.
|
|
143
|
+
* @param {string} token
|
|
144
|
+
* @param {*} value
|
|
145
|
+
*/
|
|
146
|
+
export function storeMapping(token, value) {
|
|
147
|
+
if (_seen.has(token)) return;
|
|
148
|
+
_seen.add(token);
|
|
149
|
+
ensureDir(vaultDir());
|
|
150
|
+
const line = JSON.stringify({ token, value: String(value ?? ''), ts: new Date().toISOString() }) + '\n';
|
|
151
|
+
try {
|
|
152
|
+
fs.appendFileSync(vaultFile(), line, { encoding: 'utf8', mode: 0o600 });
|
|
153
|
+
try { fs.chmodSync(vaultFile(), 0o600); } catch { /* unsupported on Windows */ }
|
|
154
|
+
} catch (err) {
|
|
155
|
+
logger.warn(`[ARGUS] Aegis vault: mapping write failed: ${err.message}`);
|
|
156
|
+
}
|
|
157
|
+
// Secret-free provenance (the token, not the value).
|
|
158
|
+
appendAudit({ action: 'tokenize', token });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Token for a value: compute the deterministic token AND persist its mapping. This
|
|
163
|
+
* is the function wired into scrubText's `token` mode (via setVaultTokenizer).
|
|
164
|
+
* @param {*} value
|
|
165
|
+
* @returns {string}
|
|
166
|
+
*/
|
|
167
|
+
export function tokenFor(value) {
|
|
168
|
+
const token = computeToken(value);
|
|
169
|
+
storeMapping(token, value);
|
|
170
|
+
return token;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Load every token→value mapping from the vault dir (all *.jsonl, merged) into a
|
|
175
|
+
* Map. Branch-agnostic so rehydrate works regardless of which branch minted a token.
|
|
176
|
+
* @returns {Map<string,string>}
|
|
177
|
+
*/
|
|
178
|
+
export function loadVault() {
|
|
179
|
+
const map = new Map();
|
|
180
|
+
const dir = vaultDir();
|
|
181
|
+
let files = [];
|
|
182
|
+
try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl')); } catch { return map; }
|
|
183
|
+
for (const f of files) {
|
|
184
|
+
let raw;
|
|
185
|
+
try { raw = fs.readFileSync(path.join(dir, f), 'utf8'); } catch { continue; }
|
|
186
|
+
for (const line of raw.split('\n')) {
|
|
187
|
+
const s = line.trim();
|
|
188
|
+
if (!s) continue;
|
|
189
|
+
try {
|
|
190
|
+
const { token, value } = JSON.parse(s);
|
|
191
|
+
if (typeof token === 'string') map.set(token, value);
|
|
192
|
+
} catch { /* skip a malformed line */ }
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return map;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Re-inflate a redacted object/string by swapping every AEGIS_ token back to its
|
|
200
|
+
* original from the vault. Unknown tokens (no mapping) are left untouched. Returns a
|
|
201
|
+
* BRAND-NEW value — never mutates the input.
|
|
202
|
+
* @param {*} obj
|
|
203
|
+
* @param {Map<string,string>} [vault]
|
|
204
|
+
* @returns {*}
|
|
205
|
+
*/
|
|
206
|
+
export function rehydrate(obj, vault) {
|
|
207
|
+
const map = vault || loadVault();
|
|
208
|
+
const walk = (v) => {
|
|
209
|
+
if (typeof v === 'string') {
|
|
210
|
+
return v.replace(TOKEN_RE, (tok) => (map.has(tok) ? map.get(tok) : tok));
|
|
211
|
+
}
|
|
212
|
+
if (Array.isArray(v)) return v.map(walk);
|
|
213
|
+
if (v && typeof v === 'object') {
|
|
214
|
+
const out = {};
|
|
215
|
+
for (const k of Object.keys(v)) out[k] = walk(v[k]);
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
return v;
|
|
219
|
+
};
|
|
220
|
+
return walk(obj);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Wire (or un-wire) the scrubText `token` mode seam. When the vault is enabled,
|
|
225
|
+
* registers tokenFor as the tokenizer; otherwise ensures the seam is OFF so token
|
|
226
|
+
* mode falls back to mask (fail-closed). Idempotent + cheap — the egress path calls
|
|
227
|
+
* this before redacting in token mode.
|
|
228
|
+
* @returns {boolean} whether the vault is now wired
|
|
229
|
+
*/
|
|
230
|
+
export function ensureVaultWired() {
|
|
231
|
+
if (vaultEnabled()) {
|
|
232
|
+
if (!_wired) { setVaultTokenizer(tokenFor); _wired = true; }
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
if (_wired) { setVaultTokenizer(null); _wired = false; }
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Test seam — clears cached key/branch/dedup/wiring. NOT for production use. */
|
|
240
|
+
export function _resetVaultForTests() {
|
|
241
|
+
_key = null;
|
|
242
|
+
_branch = null;
|
|
243
|
+
_wired = false;
|
|
244
|
+
_seen.clear();
|
|
245
|
+
setVaultTokenizer(null);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Wire on import when the env var is already set (the production case, where the
|
|
249
|
+
// process is launched with ARGUS_REDACT_VAULT=1). The egress path additionally calls
|
|
250
|
+
// ensureVaultWired() lazily, so an env var set after import is still honoured.
|
|
251
|
+
ensureVaultWired();
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
import { childLogger } from './logger.js';
|
|
30
30
|
import { parsePrUrl } from './pr-diff-analyzer.js';
|
|
31
31
|
import { githubFetch } from './github-api.js';
|
|
32
|
+
import { redactForEgress, redactReport, buildRedactionRider } from './sensitivity-classifier.js';
|
|
32
33
|
|
|
33
34
|
const logger = childLogger('github-reporter');
|
|
34
35
|
|
|
@@ -130,6 +131,12 @@ export function formatPrComment(report, diff) {
|
|
|
130
131
|
`**Base URL**: ${baseUrl} `,
|
|
131
132
|
`**Run time**: ${runDate} `,
|
|
132
133
|
'',
|
|
134
|
+
// Aegis (Step 6): when findings were redacted at the egress boundary, say so — the
|
|
135
|
+
// reader sees titles/types, never the raw exploit detail. Additive + gated, so a report
|
|
136
|
+
// that carries no `redaction` rider (every pre-Aegis caller / opt-out) renders unchanged.
|
|
137
|
+
...(report.redaction && report.redaction.redacted > 0
|
|
138
|
+
? [`> 🔒 ${report.redaction.redacted} finding(s) redacted at the boundary — full detail in the local report`, '']
|
|
139
|
+
: []),
|
|
133
140
|
...(report.prValidation ? prValidationBanner(report.prValidation) : []),
|
|
134
141
|
'| | 🔴 Critical | 🟡 Warning | 🔵 Info | Total |',
|
|
135
142
|
'|---|---|---|---|---|',
|
|
@@ -524,9 +531,20 @@ export function isGitHubConfigured() {
|
|
|
524
531
|
export async function reportToGitHub(report, diff) {
|
|
525
532
|
const tasks = [];
|
|
526
533
|
|
|
534
|
+
// Aegis egress guard (Step 6): GitHub (PR comment, Check Run, commit status) is a
|
|
535
|
+
// third-party sink read by anyone with repo access (A2). Project the WHOLE report once
|
|
536
|
+
// through redactReport — every finding array reduced to the deny-by-default allowlist, a
|
|
537
|
+
// `redaction` rider attached (drives the comment's 🔒 notice), metadata + severity counts
|
|
538
|
+
// preserved (so buildStatusPayload's merge gate is unchanged). The findings are already
|
|
539
|
+
// step-3c-tagged in the runCrawl path, so redactReport honours each finding's `sensitive`
|
|
540
|
+
// flag + the report-level `_aegisFailClosed`. ARGUS_REDACT_SENSITIVE=0 ⇒ raw passthrough.
|
|
541
|
+
const guarded = process.env.ARGUS_REDACT_SENSITIVE === '0'
|
|
542
|
+
? report
|
|
543
|
+
: redactReport(report, { localReportPath: process.env.ARGUS_REPORT_URL || null });
|
|
544
|
+
|
|
527
545
|
if (process.env.GITHUB_PR_NUMBER) {
|
|
528
546
|
tasks.push(
|
|
529
|
-
postPrComment(
|
|
547
|
+
postPrComment(guarded, diff).catch(err =>
|
|
530
548
|
logger.warn(`[ARGUS] C2: PR comment failed — ${err.message}`)
|
|
531
549
|
)
|
|
532
550
|
);
|
|
@@ -535,7 +553,7 @@ export async function reportToGitHub(report, diff) {
|
|
|
535
553
|
if (process.env.GITHUB_SHA) {
|
|
536
554
|
// Commit status (fast, minimal)
|
|
537
555
|
tasks.push(
|
|
538
|
-
setCommitStatus(
|
|
556
|
+
setCommitStatus(guarded, diff).catch(err =>
|
|
539
557
|
logger.warn(`[ARGUS] C2: Commit status failed — ${err.message}`)
|
|
540
558
|
)
|
|
541
559
|
);
|
|
@@ -543,7 +561,7 @@ export async function reportToGitHub(report, diff) {
|
|
|
543
561
|
// Check Run (rich output — created and completed in sequence)
|
|
544
562
|
tasks.push(
|
|
545
563
|
createCheckRun(undefined, process.env.GITHUB_SHA)
|
|
546
|
-
.then(id => completeCheckRun(id,
|
|
564
|
+
.then(id => completeCheckRun(id, guarded, diff))
|
|
547
565
|
.catch(err =>
|
|
548
566
|
logger.warn(`[ARGUS] C2: Check run failed — ${err.message}`)
|
|
549
567
|
)
|
|
@@ -690,7 +708,18 @@ export async function reportPrValidation(result, { prUrl } = {}) {
|
|
|
690
708
|
return { posted: false, checked: false, skipped: true, reason: 'no resolvable repo / PR number — PR reporting skipped' };
|
|
691
709
|
}
|
|
692
710
|
|
|
693
|
-
|
|
711
|
+
// Aegis egress guard (Step 6): the PR-Validator path reaches here with RAW findings (it does
|
|
712
|
+
// not run report-processor step 3c). Project them through redactForEgress BEFORE prResultToReport
|
|
713
|
+
// builds the comment, so the posted PR comment / Check Run carry titles + scrubbed messages, never
|
|
714
|
+
// exploit detail (A1/A2). The block decision was already computed upstream on the RAW findings.
|
|
715
|
+
// Attach a `redaction` rider so formatPrComment renders the 🔒 notice. Opt-out ⇒ raw passthrough.
|
|
716
|
+
const guardedResult = process.env.ARGUS_REDACT_SENSITIVE === '0'
|
|
717
|
+
? result
|
|
718
|
+
: { ...result, findings: redactForEgress(result?.findings ?? []) };
|
|
719
|
+
const report = prResultToReport(guardedResult);
|
|
720
|
+
if (process.env.ARGUS_REDACT_SENSITIVE !== '0') {
|
|
721
|
+
report.redaction = buildRedactionRider(result?.findings ?? [], { localReportPath: null });
|
|
722
|
+
}
|
|
694
723
|
// Non-first diff so findings render (not treated as a baseline-establishing first run). The
|
|
695
724
|
// new/persisting split rides on each finding's `isNew` tag (set in the PR-validate paths via
|
|
696
725
|
// tagFindingNovelty); `resolvedCount` comes from the head-vs-base diff (Phase B2) so the
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aegis — governance seam (the opt-in, self-hosted central-policy bridge).
|
|
3
|
+
*
|
|
4
|
+
* The shipped engine applies a structured redaction policy locally via the
|
|
5
|
+
* `policy` param / ARGUS_REDACT_POLICY env (redaction-policy.js). This module is
|
|
6
|
+
* the THIN, OPT-IN network wrapper that lets a self-hosted laptop / CI run
|
|
7
|
+
* *participate in central governance*: it fetches the org's signed policy from the
|
|
8
|
+
* closed control plane, VERIFIES it (Ed25519, MITM-resistant), applies it through
|
|
9
|
+
* the existing `policy` param, caches it with a TTL, and posts SECRET-FREE
|
|
10
|
+
* redaction aggregates back for the compliance trail.
|
|
11
|
+
*
|
|
12
|
+
* OPEN-CORE DISCIPLINE (AEGIS_FOR_TEAMS_PLAN.md §9 — re-read before editing):
|
|
13
|
+
* • Inert without a governance token. `ARGUS_GOV_TOKEN` unset ⇒ this module wires
|
|
14
|
+
* a source that returns null ⇒ the engine is BYTE-IDENTICAL to v9.9.0. No
|
|
15
|
+
* phone-home, no fetch, no new required dependency, no perf cost by default.
|
|
16
|
+
* • Fail-CLOSED everywhere. Any fetch / parse / signature error ⇒ the engine
|
|
17
|
+
* falls back to the STRICT floor (redact MORE, never less). A verify failure
|
|
18
|
+
* NEVER loosens redaction — it is treated exactly as "no policy fetched".
|
|
19
|
+
* • Ed25519-signed policy, verified before use. The control plane signs; the
|
|
20
|
+
* engine verifies with the org's PUBLIC key (ARGUS_REDACT_POLICY_PUBKEY, pinned
|
|
21
|
+
* out-of-band). A bad signature / wrong key / tampered body is rejected → floor.
|
|
22
|
+
* • Secret-free aggregates only. What posts to ARGUS_REDACT_AUDIT_URL is the
|
|
23
|
+
* `byReason` LABEL stream (secret:* / pii:* / category:* counts) — never a
|
|
24
|
+
* secret, token, or raw value. Best-effort: posting never blocks or fails a run.
|
|
25
|
+
* • Strict enforcement clamps the local opt-out. When a gov token is present a
|
|
26
|
+
* dev cannot set ARGUS_REDACT_SENSITIVE=0 to escape the org policy — that clamp
|
|
27
|
+
* is exposed to the classifier via redaction-policy.js governanceOptOutClamped().
|
|
28
|
+
*
|
|
29
|
+
* Uses only node:crypto + the global fetch (both built-in on the supported Node) —
|
|
30
|
+
* NO new dependency. The pure policy resolver stays in redaction-policy.js (I/O-
|
|
31
|
+
* free); this module does the I/O and injects a sync source into it, mirroring the
|
|
32
|
+
* aegis-vault.js ⇄ secret-patterns.js setVaultTokenizer seam.
|
|
33
|
+
*
|
|
34
|
+
* DEFERRED (needs the central control plane's team-vault endpoint): routing
|
|
35
|
+
* `mode=token` vault writes/reads to the team endpoint when a gov token is present.
|
|
36
|
+
* Today `mode=token` without a wired vault degrades to mask in-engine (unchanged).
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { verify as edVerify, createPublicKey } from 'node:crypto';
|
|
40
|
+
import { childLogger } from './logger.js';
|
|
41
|
+
import { scrubText } from './secret-patterns.js';
|
|
42
|
+
import { resolvePolicy, STRICT_DEFAULT, setGovernanceSource } from './redaction-policy.js';
|
|
43
|
+
|
|
44
|
+
const logger = childLogger('aegis');
|
|
45
|
+
|
|
46
|
+
const DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 min — a policy is re-fetched at most this often
|
|
47
|
+
|
|
48
|
+
// Module-level policy cache. `ok` distinguishes a verified policy (apply the
|
|
49
|
+
// resolved config) from a fetch/verify failure (fail-closed to the strict floor).
|
|
50
|
+
let _cache = { resolved: STRICT_DEFAULT, fetchedAt: null, ok: false, version: null, error: null };
|
|
51
|
+
|
|
52
|
+
/** True iff a governance token is provisioned — the master switch for the seam. */
|
|
53
|
+
export function governanceActive() {
|
|
54
|
+
return !!process.env.ARGUS_GOV_TOKEN;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function ttlMs() {
|
|
58
|
+
const raw = Number(process.env.ARGUS_REDACT_POLICY_TTL_MS);
|
|
59
|
+
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_TTL_MS;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function pubKeyPem() {
|
|
63
|
+
return process.env.ARGUS_REDACT_POLICY_PUBKEY || '';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The sync source injected into redaction-policy.js. Returns:
|
|
68
|
+
* • null — governance inactive (no token) ⇒ engine keeps env/default
|
|
69
|
+
* • resolved config — active + a fresh verified policy ⇒ apply it
|
|
70
|
+
* • STRICT_DEFAULT — active but cold / expired / failed ⇒ fail-CLOSED strict floor
|
|
71
|
+
* Never throws.
|
|
72
|
+
*/
|
|
73
|
+
function governanceSource() {
|
|
74
|
+
if (!governanceActive()) return null;
|
|
75
|
+
if (_cache.ok && _cache.fetchedAt !== null && (Date.now() - _cache.fetchedAt) < ttlMs()) {
|
|
76
|
+
return _cache.resolved;
|
|
77
|
+
}
|
|
78
|
+
return STRICT_DEFAULT; // active but no valid policy in force ⇒ strict floor
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Register the source at module load: merely importing this module makes the
|
|
82
|
+
// engine governance-aware (inert until a token is set). Mirrors setVaultTokenizer.
|
|
83
|
+
setGovernanceSource(governanceSource);
|
|
84
|
+
|
|
85
|
+
/** Key-sorted JSON — the exact bytes the control plane signs over. Must match the
|
|
86
|
+
* cloud signer's canonicalize() so a policy signed there verifies here. */
|
|
87
|
+
export function canonicalPolicyBytes(policy) {
|
|
88
|
+
return JSON.stringify(sortKeys(policy));
|
|
89
|
+
}
|
|
90
|
+
function sortKeys(value) {
|
|
91
|
+
if (Array.isArray(value)) return value.map(sortKeys);
|
|
92
|
+
if (value && typeof value === 'object') {
|
|
93
|
+
const out = {};
|
|
94
|
+
for (const k of Object.keys(value).sort()) out[k] = sortKeys(value[k]);
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
return value;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Decode an Ed25519 signature that may arrive hex or base64/base64url. */
|
|
101
|
+
function decodeSignature(sig) {
|
|
102
|
+
if (/^[0-9a-fA-F]+$/.test(sig) && sig.length % 2 === 0) return Buffer.from(sig, 'hex');
|
|
103
|
+
return Buffer.from(sig.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Verify a signed-policy payload against the pinned org public key. Returns the
|
|
108
|
+
* inner policy doc on success; THROWS on any problem (missing fields, no pubkey,
|
|
109
|
+
* bad signature, wrong key) so the caller fails closed. Exported for unit proof.
|
|
110
|
+
* @param {{policy?:object, signature?:string}} payload
|
|
111
|
+
* @param {string} [publicKeyPem]
|
|
112
|
+
* @returns {object} the verified policy doc
|
|
113
|
+
*/
|
|
114
|
+
export function verifySignedPolicy(payload, publicKeyPem = pubKeyPem()) {
|
|
115
|
+
if (!payload || typeof payload !== 'object') throw new Error('governance policy payload is not an object');
|
|
116
|
+
const { policy, signature } = payload;
|
|
117
|
+
if (!policy || typeof policy !== 'object') throw new Error('governance policy payload missing "policy"');
|
|
118
|
+
if (typeof signature !== 'string' || signature.length === 0) throw new Error('governance policy payload missing "signature"');
|
|
119
|
+
if (!publicKeyPem) throw new Error('ARGUS_REDACT_POLICY_PUBKEY is not set — cannot verify a signed policy');
|
|
120
|
+
|
|
121
|
+
const key = createPublicKey({ key: publicKeyPem, format: 'pem' });
|
|
122
|
+
const ok = edVerify(null, Buffer.from(canonicalPolicyBytes(policy), 'utf8'), key, decodeSignature(signature));
|
|
123
|
+
if (!ok) throw new Error('governance policy signature verification failed');
|
|
124
|
+
return policy;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function fetchSignedPolicy(fetchImpl) {
|
|
128
|
+
const url = process.env.ARGUS_REDACT_POLICY_URL;
|
|
129
|
+
const token = process.env.ARGUS_GOV_TOKEN;
|
|
130
|
+
if (!url) throw new Error('ARGUS_REDACT_POLICY_URL is not set');
|
|
131
|
+
if (!token) throw new Error('ARGUS_GOV_TOKEN is not set');
|
|
132
|
+
const res = await fetchImpl(url, {
|
|
133
|
+
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
|
134
|
+
});
|
|
135
|
+
if (!res || res.ok !== true) throw new Error(`governance policy fetch HTTP ${res && res.status}`);
|
|
136
|
+
return res.json();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Fetch + Ed25519-verify + cache the org policy. Idempotent + TTL-gated: at most
|
|
141
|
+
* one fetch per TTL window. Best-effort and NEVER throws — on any error it caches
|
|
142
|
+
* the STRICT floor (fail-closed) and returns it. Call once at run start (before the
|
|
143
|
+
* first redaction); the verified config then feeds every redaction via the source.
|
|
144
|
+
*
|
|
145
|
+
* @param {{ fetchImpl?: Function, now?: () => number }} [deps] test seams
|
|
146
|
+
* @returns {Promise<typeof STRICT_DEFAULT | null>} null when inactive
|
|
147
|
+
*/
|
|
148
|
+
export async function ensureGovernancePolicy(deps = {}) {
|
|
149
|
+
if (!governanceActive()) return null; // opt-in: no token ⇒ nothing fetched, byte-identical
|
|
150
|
+
const now = (deps.now || Date.now)();
|
|
151
|
+
// Fresh cache (success OR recent failure) ⇒ don't re-hit the endpoint every run.
|
|
152
|
+
if (_cache.fetchedAt !== null && (now - _cache.fetchedAt) < ttlMs()) {
|
|
153
|
+
return _cache.ok ? _cache.resolved : STRICT_DEFAULT;
|
|
154
|
+
}
|
|
155
|
+
const fetchImpl = deps.fetchImpl || globalThis.fetch;
|
|
156
|
+
try {
|
|
157
|
+
if (typeof fetchImpl !== 'function') throw new Error('no fetch implementation available');
|
|
158
|
+
const payload = await fetchSignedPolicy(fetchImpl);
|
|
159
|
+
const doc = verifySignedPolicy(payload); // throws on bad sig / wrong key / missing pubkey
|
|
160
|
+
_cache = { resolved: resolvePolicy(doc), fetchedAt: now, ok: true, version: typeof doc.version === 'number' ? doc.version : null, error: null };
|
|
161
|
+
logger.info(`[ARGUS] Aegis governance: applied org policy v${_cache.version ?? '?'} (verified)`);
|
|
162
|
+
return _cache.resolved;
|
|
163
|
+
} catch (err) {
|
|
164
|
+
_cache = { resolved: STRICT_DEFAULT, fetchedAt: now, ok: false, version: null, error: String((err && err.message) || err) };
|
|
165
|
+
logger.warn(`[ARGUS] Aegis governance: policy fetch/verify failed — failing closed to strict floor: ${_cache.error}`);
|
|
166
|
+
return STRICT_DEFAULT;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Coerce a byReason map into a SECRET-FREE {label:int}. Each label is scrubbed
|
|
171
|
+
* (defence in depth — reason labels are structural, but a tainted one dies here)
|
|
172
|
+
* and length-capped; each value is a non-negative integer. */
|
|
173
|
+
function safeByReason(byReason) {
|
|
174
|
+
const out = {};
|
|
175
|
+
if (!byReason || typeof byReason !== 'object') return out;
|
|
176
|
+
for (const [rawKey, rawVal] of Object.entries(byReason)) {
|
|
177
|
+
const label = scrubText(String(rawKey)).slice(0, 64);
|
|
178
|
+
const n = Number(rawVal);
|
|
179
|
+
if (label && Number.isFinite(n) && n >= 0) out[label] = Math.floor(n);
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function safeInt(v) {
|
|
185
|
+
const n = Number(v);
|
|
186
|
+
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* POST a SECRET-FREE redaction aggregate to ARGUS_REDACT_AUDIT_URL (the compliance
|
|
191
|
+
* trail). Best-effort: never blocks, never throws, never sends a secret. The body
|
|
192
|
+
* carries only label counts + coarse non-secret meta + the enforced policy version.
|
|
193
|
+
* No-op (not-configured) unless a gov token AND an audit URL are set.
|
|
194
|
+
*
|
|
195
|
+
* @param {{ total?:number, redacted?:number, byReason?:object }} summary from summarizeRedaction
|
|
196
|
+
* @param {{ fetchImpl?: Function, meta?: object }} [deps]
|
|
197
|
+
* @returns {Promise<{posted:boolean, reason?:string, body?:object}>}
|
|
198
|
+
*/
|
|
199
|
+
export async function postRedactionAggregate(summary, deps = {}) {
|
|
200
|
+
const url = process.env.ARGUS_REDACT_AUDIT_URL;
|
|
201
|
+
const token = process.env.ARGUS_GOV_TOKEN;
|
|
202
|
+
if (!url || !token) return { posted: false, reason: 'not-configured' };
|
|
203
|
+
|
|
204
|
+
const meta = deps.meta && typeof deps.meta === 'object' ? deps.meta : {};
|
|
205
|
+
const body = {
|
|
206
|
+
total: safeInt(summary && summary.total),
|
|
207
|
+
redacted: safeInt(summary && summary.redacted),
|
|
208
|
+
byReason: safeByReason(summary && summary.byReason),
|
|
209
|
+
policyVersion: _cache.version,
|
|
210
|
+
// Coarse, non-secret provenance only — every string is scrubbed.
|
|
211
|
+
runRef: typeof meta.runRef === 'string' ? scrubText(meta.runRef).slice(0, 128) : null,
|
|
212
|
+
at: new Date().toISOString(),
|
|
213
|
+
};
|
|
214
|
+
try {
|
|
215
|
+
const fetchImpl = deps.fetchImpl || globalThis.fetch;
|
|
216
|
+
if (typeof fetchImpl !== 'function') throw new Error('no fetch implementation available');
|
|
217
|
+
await fetchImpl(url, {
|
|
218
|
+
method: 'POST',
|
|
219
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
220
|
+
body: JSON.stringify(body),
|
|
221
|
+
});
|
|
222
|
+
return { posted: true, body };
|
|
223
|
+
} catch (err) {
|
|
224
|
+
logger.debug(`[ARGUS] Aegis governance: aggregate POST failed (best-effort, ignored): ${String((err && err.message) || err)}`);
|
|
225
|
+
return { posted: false, reason: String((err && err.message) || err), body };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The policy version currently in force (for callers/telemetry). null when none. */
|
|
230
|
+
export function governancePolicyVersion() {
|
|
231
|
+
return governanceActive() && _cache.ok ? _cache.version : null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Test seam: reset the module cache to the cold fail-closed state. */
|
|
235
|
+
export function _resetGovernanceForTests() {
|
|
236
|
+
_cache = { resolved: STRICT_DEFAULT, fetchedAt: null, ok: false, version: null, error: null };
|
|
237
|
+
}
|