@clear-capabilities/agentic-security-scanner 0.175.0 → 0.176.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/CHANGELOG.md +16 -0
- package/bin/agentic-security.js +199 -28
- package/dist/2066.index.js +850 -0
- package/dist/2199.index.js +1 -1
- package/dist/2584.index.js +3332 -0
- package/dist/2923.index.js +13 -0
- package/dist/2946.index.js +81 -0
- package/dist/3196.index.js +27 -0
- package/dist/3727.index.js +292 -0
- package/dist/4168.index.js +509 -0
- package/dist/4281.index.js +993 -0
- package/dist/4978bec8fdf5c733dc85.json +283 -0
- package/dist/5099.index.js +96 -0
- package/dist/5616.index.js +1471 -0
- package/dist/5620.index.js +2370 -0
- package/dist/5817.index.js +714 -0
- package/dist/6333.index.js +98 -0
- package/dist/6655.index.js +3221 -0
- package/dist/7838.index.js +446 -974
- package/dist/818.index.js +4 -3
- package/dist/83.index.js +159 -0
- package/dist/8551.index.js +82 -0
- package/dist/8633.index.js +93 -0
- package/dist/8714.index.js +76 -76
- package/dist/8825.index.js +84 -0
- package/dist/932.index.js +186 -0
- package/dist/9560.index.js +358 -19
- package/dist/9800.index.js +6 -1
- package/dist/agentic-security.mjs +10 -10
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/df1e8683e7eda7b2b79c.js +29 -0
- package/package.json +5 -4
- package/src/artifact/CLAUDE.md +4 -0
- package/src/artifact/artifact-evidence.js +63 -0
- package/src/artifact/cosign.js +61 -0
- package/src/artifact/engine-source.js +72 -0
- package/src/artifact/oci-layout.js +47 -0
- package/src/artifact/registry.js +213 -0
- package/src/capabilities/tool-registry.js +2 -0
- package/src/lineage/CLAUDE.md +4 -0
- package/src/lineage/identity-token.js +11 -0
- package/src/lineage/observation-adapters.js +13 -0
- package/src/lineage/runtime-edge-states.js +139 -0
- package/src/lineage/runtime-explorer-view.js +82 -0
- package/src/lineage/runtime-observation.js +11 -0
- package/src/mcp/CLAUDE.md +3 -1
- package/src/mcp/proof-tools.js +132 -0
- package/src/mcp/server.js +1 -1
- package/src/mcp/tools.js +3 -1
- package/src/posture/CLAUDE.md +23 -0
- package/src/posture/artifact-registry.js +4 -0
- package/src/posture/dnr/attack-facts.js +31 -0
- package/src/posture/dnr/cli.js +138 -0
- package/src/posture/dnr/detection-ir.js +123 -0
- package/src/posture/dnr/exposure-window.js +134 -0
- package/src/posture/dnr/hunt.js +84 -0
- package/src/posture/dnr/log-reader.js +189 -0
- package/src/posture/dnr/matchers.js +90 -0
- package/src/posture/dnr/outputs.js +262 -0
- package/src/posture/dnr/payload-classes.js +120 -0
- package/src/posture/dnr/redact.js +25 -0
- package/src/posture/dnr/store.js +57 -0
- package/src/posture/dnr/validate.js +64 -0
- package/src/posture/dnr/view.js +51 -0
- package/src/posture/evaluation/lab/arm-ollama.js +88 -0
- package/src/posture/evaluation/lab/arms.js +142 -0
- package/src/posture/evaluation/lab/cli.js +135 -0
- package/src/posture/evaluation/lab/dataset.js +139 -0
- package/src/posture/evaluation/lab/materialize.js +44 -0
- package/src/posture/evaluation/lab/neutralize.js +144 -0
- package/src/posture/evaluation/lab/report.js +176 -0
- package/src/posture/evaluation/lab/run.js +225 -0
- package/src/posture/evaluation/lab/score.js +135 -0
- package/src/posture/evaluation/lab/scoreboard.js +48 -0
- package/src/posture/evaluation/lab/stats.js +41 -0
- package/src/posture/evaluation/lab/store.js +53 -0
- package/src/posture/fleet/cli.js +248 -63
- package/src/posture/fleet/discover-edges.js +135 -0
- package/src/posture/fleet/enterprise-graph.js +148 -0
- package/src/posture/fleet/fleet-graph.js +9 -4
- package/src/posture/fleet/fleet-manifest.js +50 -5
- package/src/posture/fleet/job-runner.js +153 -0
- package/src/posture/fleet/job-store.js +136 -0
- package/src/posture/fleet/org-health.js +56 -0
- package/src/posture/fleet/scan-cache.js +112 -0
- package/src/posture/fleet/scan-child.js +29 -0
- package/src/posture/proof-fabric/cli.js +34 -6
- package/src/posture/proof-fabric/container.js +57 -0
- package/src/posture/proof-fabric/fix-proof.js +2 -1
- package/src/posture/proof-fabric/generate-python.js +129 -0
- package/src/posture/proof-fabric/generate.js +213 -0
- package/src/posture/proof-fabric/reproducer.js +3 -0
- package/src/posture/proof-fabric/runner.js +20 -11
- package/src/posture/proof-fabric/siblings.js +77 -0
- package/src/posture/routing/policy-router.js +103 -0
- package/src/posture/rule-compiler/cli.js +64 -6
- package/src/posture/rule-compiler/corpus.js +10 -2
- package/src/posture/rule-compiler/mutation.js +72 -1
- package/src/posture/rule-compiler/promotion.js +21 -5
- package/src/posture/rule-compiler/proposal.js +132 -0
- package/src/posture/rule-compiler/store.js +16 -0
|
@@ -0,0 +1,2370 @@
|
|
|
1
|
+
export const id = 5620;
|
|
2
|
+
export const ids = [5620,3736];
|
|
3
|
+
export const modules = {
|
|
4
|
+
|
|
5
|
+
/***/ 53948:
|
|
6
|
+
/***/ ((module, __unused_webpack_exports, __webpack_require__) => {
|
|
7
|
+
|
|
8
|
+
module.exports = __webpack_require__.p + "df1e8683e7eda7b2b79c.js";
|
|
9
|
+
|
|
10
|
+
/***/ }),
|
|
11
|
+
|
|
12
|
+
/***/ 80313:
|
|
13
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
14
|
+
|
|
15
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
16
|
+
/* harmony export */ JQ: () => (/* binding */ rollupFleet),
|
|
17
|
+
/* harmony export */ LL: () => (/* binding */ renderFleetSummary),
|
|
18
|
+
/* harmony export */ gI: () => (/* binding */ runFleet)
|
|
19
|
+
/* harmony export */ });
|
|
20
|
+
/* unused harmony exports loadFleetState, ownersFor, renderFleetHtml, _internals */
|
|
21
|
+
/* harmony import */ var node_fs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(73024);
|
|
22
|
+
/* harmony import */ var node_path__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(76760);
|
|
23
|
+
/* harmony import */ var _policy_bundle_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(23736);
|
|
24
|
+
// PRD Epic 5 — fleet orchestration.
|
|
25
|
+
//
|
|
26
|
+
// Many repositories, one invocation, one rolled-up result set. The existing
|
|
27
|
+
// `org-scan` could iterate repos; what it could not do is survive an
|
|
28
|
+
// interruption, bound its own concurrency, tell you what is NEW since the last
|
|
29
|
+
// run, or route a finding to the person who owns the file. Those four are the
|
|
30
|
+
// difference between a loop and something an organisation can put on a
|
|
31
|
+
// schedule.
|
|
32
|
+
//
|
|
33
|
+
// THE HARD LINE (PRD §2.2, §10): everything here runs from the CLI on the
|
|
34
|
+
// customer's infrastructure. There is no server, no account, no multi-tenancy,
|
|
35
|
+
// and the "UI" is a static artifact generated from local files. A fleet feature
|
|
36
|
+
// is exactly where a local-first tool drifts into a platform, so the constraint
|
|
37
|
+
// is stated here rather than left to judgement.
|
|
38
|
+
//
|
|
39
|
+
// FOUR PROPERTIES, EACH OF WHICH IS A FAILURE MODE IF ABSENT:
|
|
40
|
+
//
|
|
41
|
+
// 1. ISOLATION — one repo that explodes must not take the run with it. A
|
|
42
|
+
// fleet scan that dies on repo 3 of 50 is worse than useless: it looks
|
|
43
|
+
// like a completed scan of 3 repos.
|
|
44
|
+
// 2. RESUMABILITY — the checkpoint records COMPLETED repos, and is written
|
|
45
|
+
// after each one. A resumed run skips them rather than re-scanning, which
|
|
46
|
+
// is what makes a 50-repo run survivable on a laptop or a flaky runner.
|
|
47
|
+
// 3. BOUNDED CONCURRENCY — an unbounded fan-out over 50 repos will exhaust
|
|
48
|
+
// memory or file handles on the machine it was supposed to help.
|
|
49
|
+
// 4. HONEST ROLL-UP — a repo that FAILED is counted as failed, never folded
|
|
50
|
+
// into "0 findings". Silent zeros are how a fleet report says everything
|
|
51
|
+
// is fine because nothing ran.
|
|
52
|
+
//
|
|
53
|
+
// DELTA IS COMPUTED FROM STABLE IDS, not counts. "3 more criticals than last
|
|
54
|
+
// week" tells you nothing about which; a set difference over stableId tells you
|
|
55
|
+
// exactly what appeared, and is what a scheduled run should notify on.
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
const SCHEMA = 'agentic-security/fleet-state@1';
|
|
62
|
+
|
|
63
|
+
/** Read a checkpoint, or an empty one. Never throws. */
|
|
64
|
+
function loadFleetState(stateFile) {
|
|
65
|
+
try {
|
|
66
|
+
const j = JSON.parse(node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(stateFile, 'utf8'));
|
|
67
|
+
if (j && j.schema === SCHEMA) return j;
|
|
68
|
+
} catch { /* absent or unreadable -> start clean */ }
|
|
69
|
+
return { schema: SCHEMA, completed: {}, startedAt: null };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function _saveFleetState(stateFile, state) {
|
|
73
|
+
try {
|
|
74
|
+
node_fs__WEBPACK_IMPORTED_MODULE_0__.mkdirSync(node_path__WEBPACK_IMPORTED_MODULE_1__.dirname(stateFile), { recursive: true });
|
|
75
|
+
node_fs__WEBPACK_IMPORTED_MODULE_0__.writeFileSync(stateFile, JSON.stringify(state, null, 2));
|
|
76
|
+
return true;
|
|
77
|
+
} catch { return false; }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Owners for a path, from a CODEOWNERS file.
|
|
82
|
+
*
|
|
83
|
+
* Last matching rule wins, which is the documented CODEOWNERS precedence — a
|
|
84
|
+
* first-match implementation silently routes to the most general owner, which
|
|
85
|
+
* is the one least able to act on the finding.
|
|
86
|
+
*/
|
|
87
|
+
function ownersFor(codeownersText, filePath) {
|
|
88
|
+
if (typeof codeownersText !== 'string' || !filePath) return [];
|
|
89
|
+
let owners = [];
|
|
90
|
+
for (const raw of codeownersText.split('\n')) {
|
|
91
|
+
const line = raw.replace(/#.*$/, '').trim();
|
|
92
|
+
if (!line) continue;
|
|
93
|
+
const parts = line.split(/\s+/);
|
|
94
|
+
const pattern = parts.shift();
|
|
95
|
+
const these = parts.filter(p => p.startsWith('@') || p.includes('@'));
|
|
96
|
+
if (!these.length) continue;
|
|
97
|
+
if (_matchesGlob(pattern, filePath)) owners = these;
|
|
98
|
+
}
|
|
99
|
+
return owners;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function _matchesGlob(pattern, file) {
|
|
103
|
+
const f = file.replace(/^\.?\//, '');
|
|
104
|
+
let p = pattern.replace(/^\.?\//, '');
|
|
105
|
+
if (p === '*') return true;
|
|
106
|
+
if (p.endsWith('/')) return f.startsWith(p);
|
|
107
|
+
const PLACEHOLDER = '\uE000'; // escape SEQUENCE in source -- 6 ASCII chars, not a raw embedded byte
|
|
108
|
+
const rx = new RegExp('^' + p
|
|
109
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
110
|
+
.replace(/\*\*/g, PLACEHOLDER)
|
|
111
|
+
.replace(/\*/g, '[^/]*')
|
|
112
|
+
.replace(new RegExp(PLACEHOLDER, 'g'), '.*') + '(/.*)?$');
|
|
113
|
+
return rx.test(f);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function _readCodeowners(repoRoot) {
|
|
117
|
+
for (const rel of ['CODEOWNERS', '.github/CODEOWNERS', 'docs/CODEOWNERS']) {
|
|
118
|
+
try { return node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(node_path__WEBPACK_IMPORTED_MODULE_1__.join(repoRoot, rel), 'utf8'); } catch { /* next */ }
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const SEVERITIES = ['critical', 'high', 'medium', 'low', 'info'];
|
|
124
|
+
|
|
125
|
+
function _summarise(scan) {
|
|
126
|
+
const all = [
|
|
127
|
+
...(scan.findings || []), ...(scan.secrets || []),
|
|
128
|
+
...(scan.logicVulns || []), ...(scan.supplyChain || []),
|
|
129
|
+
];
|
|
130
|
+
const bySeverity = Object.fromEntries(SEVERITIES.map(s => [s, 0]));
|
|
131
|
+
const ids = [];
|
|
132
|
+
let proven = 0;
|
|
133
|
+
for (const f of all) {
|
|
134
|
+
const sev = String(f.severity || 'info').toLowerCase();
|
|
135
|
+
if (sev in bySeverity) bySeverity[sev]++;
|
|
136
|
+
if (f.stableId) ids.push(f.stableId);
|
|
137
|
+
if (f.proofTier === 'execution-proven') proven++;
|
|
138
|
+
}
|
|
139
|
+
return { total: all.length, bySeverity, proven, ids, findings: all };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* M4 §4.4 — provenance-aware debt for ONE repo, computed here (inside the
|
|
144
|
+
* worker, from the untrimmed findings array) rather than inside `rollupFleet`,
|
|
145
|
+
* because FR-1005 forbids raw finding data from surviving onto the per-repo
|
|
146
|
+
* result entry (`test('FR-1005: ... the entry must not carry the raw findings
|
|
147
|
+
* array at all')` in fleet.test.js) — a finding can carry a raw source
|
|
148
|
+
* snippet, so only this derived summary (a finding id, a commit date, a day
|
|
149
|
+
* count; never a file path or snippet) is threaded onto `entry.provenanceDebt`.
|
|
150
|
+
*
|
|
151
|
+
* A finding contributes only when its `findingProvenance.status` is
|
|
152
|
+
* `'complete'` — the honest end of the confidence spectrum (see
|
|
153
|
+
* `provenance/schema.js`'s `PROVENANCE_STATUS`). `partial`/`uncommitted`/etc.
|
|
154
|
+
* are not proof of origin and must never win "oldest".
|
|
155
|
+
*/
|
|
156
|
+
function _provenanceDebtForRepo(findings, now = Date.now()) {
|
|
157
|
+
let oldest = null;
|
|
158
|
+
let completeCount = 0;
|
|
159
|
+
for (const f of (findings || [])) {
|
|
160
|
+
if (f.findingProvenance?.status !== 'complete') continue;
|
|
161
|
+
const authorDate = f.findingProvenance?.findingOrigin?.authorDate;
|
|
162
|
+
if (!authorDate) continue;
|
|
163
|
+
completeCount++;
|
|
164
|
+
const ageDays = Math.max(0, Math.floor((now - Date.parse(authorDate)) / 86400000));
|
|
165
|
+
if (!oldest || ageDays > oldest.ageDays) {
|
|
166
|
+
// FR-PROV-019: "Reports never show an age without its basis and
|
|
167
|
+
// confidence." The basis is structurally implied here (only
|
|
168
|
+
// status:'complete' findings reach this line, so the age is always
|
|
169
|
+
// git-derived, and the rendered text says "proven-origin") — but the
|
|
170
|
+
// confidence half has to be carried explicitly, same as
|
|
171
|
+
// mttr.js's renderSlaSummary. Captured at selection time so the
|
|
172
|
+
// renderers below cannot show the age without it.
|
|
173
|
+
oldest = {
|
|
174
|
+
findingId: f.id || f.stableId || null,
|
|
175
|
+
authorDate,
|
|
176
|
+
ageDays,
|
|
177
|
+
confidence: f.findingProvenance?.confidence?.level || null,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return { oldest, completeCount };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Scan many repositories with bounded concurrency, resumably.
|
|
186
|
+
*
|
|
187
|
+
* `runScan` is injected so this module has no engine import cycle and so the
|
|
188
|
+
* orchestration can be tested without running real scans.
|
|
189
|
+
*/
|
|
190
|
+
async function runFleet({
|
|
191
|
+
repos = [], concurrency = 4, stateFile, runScan,
|
|
192
|
+
resume = true, previous = null, onProgress = () => {},
|
|
193
|
+
// X-704: repositories whose every durable portfolio work unit is verified and current
|
|
194
|
+
// (portfolio/work-units.js `fullyVerifiedRepositories`). They are skipped like a resumed completed repo,
|
|
195
|
+
// and, as with a resumed repo, the skip is reported in `skipped` rather than counted as scanned.
|
|
196
|
+
portfolioVerified = [],
|
|
197
|
+
} = {}) {
|
|
198
|
+
if (typeof runScan !== 'function') {
|
|
199
|
+
return { ok: false, reason: 'no runScan supplied' };
|
|
200
|
+
}
|
|
201
|
+
const state = (resume && stateFile) ? loadFleetState(stateFile) : { schema: SCHEMA, completed: {}, startedAt: null };
|
|
202
|
+
const results = [];
|
|
203
|
+
const skipped = [];
|
|
204
|
+
|
|
205
|
+
const queue = repos.filter((r) => {
|
|
206
|
+
if ((resume && state.completed[r]) || portfolioVerified.includes(r)) { skipped.push(r); return false; }
|
|
207
|
+
return true;
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const limit = Math.max(1, Math.min(concurrency, 32));
|
|
211
|
+
let cursor = 0;
|
|
212
|
+
|
|
213
|
+
async function worker() {
|
|
214
|
+
while (cursor < queue.length) {
|
|
215
|
+
const repo = queue[cursor++];
|
|
216
|
+
let entry;
|
|
217
|
+
try {
|
|
218
|
+
const { scan } = await runScan(repo);
|
|
219
|
+
const s = _summarise(scan);
|
|
220
|
+
const codeowners = _readCodeowners(repo);
|
|
221
|
+
// Route each finding to the owner of its file. Done here rather than at
|
|
222
|
+
// report time because the CODEOWNERS file lives in the repo, and by
|
|
223
|
+
// report time the fleet has moved on.
|
|
224
|
+
const owners = {};
|
|
225
|
+
if (codeowners) {
|
|
226
|
+
for (const f of s.findings) {
|
|
227
|
+
if (!f.file) continue;
|
|
228
|
+
const o = ownersFor(codeowners, f.file);
|
|
229
|
+
for (const own of o) (owners[own] ||= []).push(f.stableId || `${f.file}:${f.line}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// FR-1006: "assurance-health views" and "policy drift" — a repo can
|
|
233
|
+
// be RISK-clean (zero/low findings) while still having a governance
|
|
234
|
+
// gap (a partial scan, a rejected/expired policy bundle nobody
|
|
235
|
+
// noticed). scanHealth comes straight from the engine's own
|
|
236
|
+
// computeScanHealth (FR-206) — nothing new is inferred here, this is
|
|
237
|
+
// the first time the fleet rollup SURFACES a signal that already
|
|
238
|
+
// existed per-scan. computePolicyDrift is null (not an empty object)
|
|
239
|
+
// when the repo has no policy bundles configured at all — matching
|
|
240
|
+
// this session's "no baseline configured -> no-op" convention.
|
|
241
|
+
let policyDrift = null;
|
|
242
|
+
try { policyDrift = (0,_policy_bundle_js__WEBPACK_IMPORTED_MODULE_2__/* .computePolicyDrift */ .rE)(repo); } catch { /* best-effort — never fail a repo's scan over drift reporting */ }
|
|
243
|
+
// M4 §4.4: provenance-aware debt, computed HERE (from `s.findings`,
|
|
244
|
+
// the untrimmed array) rather than in `rollupFleet` — by the time
|
|
245
|
+
// `rollupFleet` sees `results`, the raw findings are gone (FR-1005).
|
|
246
|
+
// Only the derived summary (id/date/age, never a file or snippet)
|
|
247
|
+
// survives onto the entry. `mttr` threads through the SAME aggregate
|
|
248
|
+
// `mttr.js`'s `computeMTTR` already produces (`scan.mttr`, stamped by
|
|
249
|
+
// the CLI's persistence step — see posture/mttr.js) rather than
|
|
250
|
+
// inventing a second notion of remediation time; there is no
|
|
251
|
+
// `scan.remediatedFindings` field anywhere in this codebase.
|
|
252
|
+
const provenanceDebt = _provenanceDebtForRepo(s.findings);
|
|
253
|
+
entry = {
|
|
254
|
+
repo, ok: true, total: s.total, bySeverity: s.bySeverity,
|
|
255
|
+
proven: s.proven, ids: s.ids, owners, scannedAt: new Date().toISOString(),
|
|
256
|
+
scanHealth: scan.scanHealth || null, policyDrift,
|
|
257
|
+
provenanceDebt, mttr: scan.mttr || null,
|
|
258
|
+
};
|
|
259
|
+
} catch (e) {
|
|
260
|
+
// A failed repo is FAILED, never zero findings.
|
|
261
|
+
entry = { repo, ok: false, error: String(e?.message || e), total: null, bySeverity: null, ids: [], scannedAt: new Date().toISOString(), scanHealth: null, policyDrift: null, provenanceDebt: null, mttr: null };
|
|
262
|
+
}
|
|
263
|
+
results.push(entry);
|
|
264
|
+
if (stateFile) {
|
|
265
|
+
// FR-1006 (assurance-hardening PRD): "scan freshness" — this field
|
|
266
|
+
// existed as an always-null stub before (never read anywhere, never
|
|
267
|
+
// populated), which is the same as not existing for anyone trying to
|
|
268
|
+
// answer "when was this repo last scanned". Stamped from the SAME
|
|
269
|
+
// entry.scannedAt the results array already carries, not a second,
|
|
270
|
+
// possibly-diverging timestamp.
|
|
271
|
+
state.completed[repo] = { at: entry.scannedAt, ok: entry.ok, total: entry.total };
|
|
272
|
+
_saveFleetState(stateFile, state);
|
|
273
|
+
}
|
|
274
|
+
onProgress(entry);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
await Promise.all(Array.from({ length: limit }, () => worker()));
|
|
279
|
+
return { ok: true, results, skipped, rollup: rollupFleet(results, previous) };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Roll many per-repo results into one view, with a delta when a previous run
|
|
284
|
+
* is supplied.
|
|
285
|
+
*/
|
|
286
|
+
function rollupFleet(results, previous = null) {
|
|
287
|
+
const scanned = results.filter(r => r.ok);
|
|
288
|
+
const failed = results.filter(r => !r.ok);
|
|
289
|
+
const bySeverity = Object.fromEntries(SEVERITIES.map(s => [s, 0]));
|
|
290
|
+
let total = 0, proven = 0;
|
|
291
|
+
for (const r of scanned) {
|
|
292
|
+
total += r.total || 0;
|
|
293
|
+
proven += r.proven || 0;
|
|
294
|
+
for (const s of SEVERITIES) bySeverity[s] += r.bySeverity?.[s] || 0;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Delta by stable id, not by count.
|
|
298
|
+
let newFindings = null;
|
|
299
|
+
if (previous && Array.isArray(previous.results)) {
|
|
300
|
+
const before = new Set(previous.results.flatMap(r => r.ids || []));
|
|
301
|
+
newFindings = {};
|
|
302
|
+
for (const r of scanned) {
|
|
303
|
+
const fresh = (r.ids || []).filter(id => !before.has(id));
|
|
304
|
+
if (fresh.length) newFindings[r.repo] = fresh;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// FR-1006: governance/coverage gaps, counted and surfaced SEPARATELY from
|
|
309
|
+
// the risk rollup above (total/bySeverity/proven) — a repo can contribute
|
|
310
|
+
// zero to those and still need attention here. `null` scanHealth (a
|
|
311
|
+
// failed repo, or a runScan that predates FR-206) is not counted as
|
|
312
|
+
// partial — absence of a signal is not evidence of a problem.
|
|
313
|
+
const partialScanHealth = scanned.filter(r => r.scanHealth && r.scanHealth.status !== 'complete');
|
|
314
|
+
const withPolicyDrift = scanned.filter(r => r.policyDrift && (r.policyDrift.overrides.length || r.policyDrift.rejected.length));
|
|
315
|
+
|
|
316
|
+
// M4 §4.4: provenance-aware debt + MTTR, surfaced as its own section
|
|
317
|
+
// alongside the FR-1006 governance section above — same additive pattern,
|
|
318
|
+
// same reason: a repo can be risk-clean and still carry old, PROVEN debt,
|
|
319
|
+
// and that must not be folded into (or hidden by) the risk rollup above.
|
|
320
|
+
//
|
|
321
|
+
// `oldestProvenDebt` only ever comes from a repo's own `provenanceDebt`,
|
|
322
|
+
// which `runFleet`'s worker computed from `findingProvenance.status ===
|
|
323
|
+
// 'complete'` findings only — never a wall-clock guess dressed as proof.
|
|
324
|
+
// A repo with no complete-status findings is disclosed BY NAME in
|
|
325
|
+
// `reposWithNoProvenDebt`, never silently absent from a "no debt" story a
|
|
326
|
+
// reader could misread as "this repo has no old findings" when the truth
|
|
327
|
+
// is "we couldn't PROVE any origin here". A `results` entry that predates
|
|
328
|
+
// this field (no `provenanceDebt` key at all — e.g. an older checkpoint,
|
|
329
|
+
// or a hand-built test fixture) is treated the same as "none found",
|
|
330
|
+
// consistent with every other optional signal in this rollup (scanHealth,
|
|
331
|
+
// policyDrift).
|
|
332
|
+
let oldestProvenDebt = null;
|
|
333
|
+
const reposWithNoProvenDebt = [];
|
|
334
|
+
const perRepoOldestProvenDebt = [];
|
|
335
|
+
for (const r of scanned) {
|
|
336
|
+
const oldest = r.provenanceDebt?.oldest || null;
|
|
337
|
+
if (!oldest) { reposWithNoProvenDebt.push(r.repo); continue; }
|
|
338
|
+
const withRepo = { repo: r.repo, ...oldest };
|
|
339
|
+
perRepoOldestProvenDebt.push(withRepo);
|
|
340
|
+
if (!oldestProvenDebt || oldest.ageDays > oldestProvenDebt.ageDays) oldestProvenDebt = withRepo;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Fleet-wide MTTR reuses each repo's own `scan.mttr` (mttr.js's
|
|
344
|
+
// `computeMTTR` output — `{count, meanDays, medianDays, perSeverity}`),
|
|
345
|
+
// combined as a count-weighted mean, rather than recomputing wall-clock
|
|
346
|
+
// remediation time here from a second source of truth. `byAgeBasis` is
|
|
347
|
+
// deliberately always `{}`: `computeMTTR` does not track ageBasis per
|
|
348
|
+
// remediated finding (only per-severity), and there is no
|
|
349
|
+
// `scan.remediatedFindings` array anywhere in this codebase to derive one
|
|
350
|
+
// from — see `runFleet`'s worker comment. Reporting a fabricated breakdown
|
|
351
|
+
// would be worse than reporting none.
|
|
352
|
+
//
|
|
353
|
+
// FINAL-REVIEW FIX (M4, item 4): `mttr` used to default to `{n:0, ...}`
|
|
354
|
+
// whenever no scanned repo's `.mttr` counted a remediation — which is
|
|
355
|
+
// exactly what happens on EVERY real production fleet run, since
|
|
356
|
+
// `scripts/fleet.mjs` drives `runFleet` with `runScan` straight from
|
|
357
|
+
// `src/runScan.js`, and that function never sets `.mttr` at all (the only
|
|
358
|
+
// real setter is the CLI's OWN single-repo persistence step in
|
|
359
|
+
// `bin/agentic-security.js`, which fleet mode does not go through — see
|
|
360
|
+
// that file's comment for the full investigation). `{n:0,...}` and "no
|
|
361
|
+
// repo ever attempted to compute this" are materially different facts:
|
|
362
|
+
// the first is a real, honest zero; the second is a missing capability.
|
|
363
|
+
// Collapsing them made every real fleet run silently render a fabricated-
|
|
364
|
+
// looking "no remediated findings recorded yet" line that reads as a
|
|
365
|
+
// measurement rather than an absence.
|
|
366
|
+
//
|
|
367
|
+
// `anyMttrSupplied` distinguishes them: it is true only when at least one
|
|
368
|
+
// SCANNED repo's entry carried a non-null `.mttr` object at all — which
|
|
369
|
+
// only happens when something upstream (the CLI's own persistence path,
|
|
370
|
+
// or a caller-supplied `runScan` that wires the same pipeline itself)
|
|
371
|
+
// actually ran `computeMTTR`. `mttr` stays `null` — not a zeroed object —
|
|
372
|
+
// when nothing did, so a consumer can tell "tracked, zero fixes so far"
|
|
373
|
+
// from "not tracked at all" without inspecting `n`.
|
|
374
|
+
let mttrCount = 0, mttrWeightedDays = 0, anyMttrSupplied = false;
|
|
375
|
+
for (const r of scanned) {
|
|
376
|
+
const m = r.mttr;
|
|
377
|
+
if (m == null) continue;
|
|
378
|
+
anyMttrSupplied = true;
|
|
379
|
+
if (typeof m.count === 'number' && m.count > 0 && typeof m.meanDays === 'number') {
|
|
380
|
+
mttrCount += m.count;
|
|
381
|
+
mttrWeightedDays += m.meanDays * m.count;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
const mttr = anyMttrSupplied
|
|
385
|
+
? { n: mttrCount, meanDays: mttrCount ? mttrWeightedDays / mttrCount : null, byAgeBasis: {} }
|
|
386
|
+
: null;
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
repos: results.length,
|
|
390
|
+
scanned: scanned.length,
|
|
391
|
+
// Surfaced at the top level, not buried: a fleet report whose failures are
|
|
392
|
+
// a footnote reads as a clean fleet.
|
|
393
|
+
failed: failed.length,
|
|
394
|
+
failures: failed.map(f => ({ repo: f.repo, error: f.error })),
|
|
395
|
+
total, proven, bySeverity,
|
|
396
|
+
reposWithPartialScanHealth: partialScanHealth.length,
|
|
397
|
+
reposWithPolicyDrift: withPolicyDrift.length,
|
|
398
|
+
...(newFindings ? { newFindings } : {}),
|
|
399
|
+
provenance: { oldestProvenDebt, reposWithNoProvenDebt, perRepoOldestProvenDebt, mttr },
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** One-line summary. Leads with failures when there are any. */
|
|
404
|
+
function renderFleetSummary(rollup) {
|
|
405
|
+
if (!rollup) return null;
|
|
406
|
+
const head = rollup.failed
|
|
407
|
+
? `${rollup.failed} of ${rollup.repos} repo(s) FAILED to scan — their findings are unknown, not zero; `
|
|
408
|
+
: '';
|
|
409
|
+
const sev = SEVERITIES.filter(s => rollup.bySeverity[s]).map(s => `${rollup.bySeverity[s]} ${s}`).join(', ');
|
|
410
|
+
const nf = rollup.newFindings
|
|
411
|
+
? ` ${Object.values(rollup.newFindings).reduce((a, b) => a + b.length, 0)} new since last run.`
|
|
412
|
+
: '';
|
|
413
|
+
// FR-1006: governance/coverage gaps reported as their own clause, never
|
|
414
|
+
// folded into the risk sentence — a fleet with zero findings but 12 repos
|
|
415
|
+
// on a partial scan is not "clean."
|
|
416
|
+
const gov = [];
|
|
417
|
+
if (rollup.reposWithPartialScanHealth) gov.push(`${rollup.reposWithPartialScanHealth} repo(s) with a partial (not complete) scan`);
|
|
418
|
+
if (rollup.reposWithPolicyDrift) gov.push(`${rollup.reposWithPolicyDrift} repo(s) with policy drift or a rejected policy bundle`);
|
|
419
|
+
const govClause = gov.length ? ` GOVERNANCE: ${gov.join('; ')}.` : '';
|
|
420
|
+
// M4 §4.4: proven-origin debt as its own clause, matching the governance
|
|
421
|
+
// clause's honesty rule — a null `oldestProvenDebt` gets an explicit "none"
|
|
422
|
+
// line rather than silently vanishing from the sentence, which would read
|
|
423
|
+
// as "no debt" instead of "nothing PROVEN yet."
|
|
424
|
+
const prov = rollup.provenance;
|
|
425
|
+
let provClause = '';
|
|
426
|
+
if (prov) {
|
|
427
|
+
// FR-PROV-019: the age never ships without its basis ("proven-origin",
|
|
428
|
+
// structurally guaranteed by the status:'complete' filter upstream) AND
|
|
429
|
+
// its confidence. A finding whose provenance carried no confidence level
|
|
430
|
+
// says so rather than implying certainty by omission.
|
|
431
|
+
const bits = prov.oldestProvenDebt
|
|
432
|
+
? [`oldest proven-origin finding: ${prov.oldestProvenDebt.repo} (${prov.oldestProvenDebt.ageDays}d, `
|
|
433
|
+
+ `${prov.oldestProvenDebt.confidence ? `${String(prov.oldestProvenDebt.confidence).toUpperCase()} confidence` : 'confidence unknown'})`]
|
|
434
|
+
: ['no proven-origin findings across the fleet'];
|
|
435
|
+
if (prov.reposWithNoProvenDebt.length) bits.push(`${prov.reposWithNoProvenDebt.length} repo(s) with no complete-status provenance`);
|
|
436
|
+
provClause = ` PROVENANCE: ${bits.join('; ')}.`;
|
|
437
|
+
}
|
|
438
|
+
// X-707: portfolio progress is its own clause, present only when the `portfolio-assurance` feature attached it
|
|
439
|
+
// (portfolio/progress.js `attachPortfolioProgress`), so flag-off output is byte-identical. It states verified units and
|
|
440
|
+
// repositories separately from the scan counts above: a finished controller is not a passing portfolio.
|
|
441
|
+
const pp = rollup.portfolioProgress;
|
|
442
|
+
const portfolioClause = pp ? ` PORTFOLIO: ${pp.units.verified}/${pp.units.total} unit(s) verified, ${pp.coverage.fullyVerified}/${pp.coverage.total} repo(s) fully verified, ${pp.review.pending} pending human review; ${pp.completion.statement}` : '';
|
|
443
|
+
return `${head}${rollup.scanned} repo(s) scanned, ${rollup.total} finding(s)`
|
|
444
|
+
+ `${sev ? ` (${sev})` : ''}, ${rollup.proven} execution-proven.${nf}${govClause}${provClause}${portfolioClause}`;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* A self-contained rollup page. No network, no scripts, no fonts — it must
|
|
449
|
+
* render from local artifacts on an air-gapped machine (PRD AC3).
|
|
450
|
+
*/
|
|
451
|
+
function renderFleetHtml(rollup, results) {
|
|
452
|
+
const esc = (s) => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
453
|
+
// FR-1006: "scan freshness" — WHEN each repo was last scanned, not just
|
|
454
|
+
// whether it was. A failed repo still carries a real scannedAt (the
|
|
455
|
+
// attempt happened; it just didn't succeed) so a reader can tell "failed
|
|
456
|
+
// just now" from "failed, and nobody has retried in weeks" apart.
|
|
457
|
+
const rows = results.map(r => r.ok
|
|
458
|
+
? `<tr><td>${esc(r.repo)}</td><td>${r.total}</td>`
|
|
459
|
+
+ SEVERITIES.map(s => `<td>${r.bySeverity?.[s] ?? 0}</td>`).join('')
|
|
460
|
+
+ `<td>${r.proven ?? 0}</td><td>${esc(r.scannedAt || 'unknown')}</td></tr>`
|
|
461
|
+
: `<tr class="fail"><td>${esc(r.repo)}</td><td colspan="7">SCAN FAILED — ${esc(r.error)}</td><td>${esc(r.scannedAt || 'unknown')}</td></tr>`).join('\n');
|
|
462
|
+
// FR-1006: a SEPARATE table, not a column bolted onto the risk table above
|
|
463
|
+
// — "distinguishes risk findings from scan and governance coverage gaps"
|
|
464
|
+
// is most honestly satisfied by keeping the two visually and structurally
|
|
465
|
+
// apart, not by merging them into one row a reader has to parse. Only
|
|
466
|
+
// repos with something to report appear here; a fleet with nothing to flag
|
|
467
|
+
// gets a one-line "nothing to report" rather than an empty table.
|
|
468
|
+
const govRows = results
|
|
469
|
+
.filter(r => (r.scanHealth && r.scanHealth.status !== 'complete') || (r.policyDrift && (r.policyDrift.overrides.length || r.policyDrift.rejected.length)))
|
|
470
|
+
.map(r => {
|
|
471
|
+
const healthCell = r.scanHealth && r.scanHealth.status !== 'complete'
|
|
472
|
+
? esc(`${r.scanHealth.status}: ${(r.scanHealth.conditions || []).join('; ') || 'see scanHealth'}`)
|
|
473
|
+
: '—';
|
|
474
|
+
const driftParts = [];
|
|
475
|
+
if (r.policyDrift?.overrides?.length) {
|
|
476
|
+
driftParts.push(...r.policyDrift.overrides.map(o => `${esc(o.key)} overridden by ${esc(o.overriddenBy)}`));
|
|
477
|
+
}
|
|
478
|
+
if (r.policyDrift?.rejected?.length) {
|
|
479
|
+
driftParts.push(...r.policyDrift.rejected.map(x => `${esc(x.scope)} policy REJECTED: ${esc(x.reason)}`));
|
|
480
|
+
}
|
|
481
|
+
const driftCell = driftParts.length ? driftParts.join('; ') : '—';
|
|
482
|
+
return `<tr><td>${esc(r.repo)}</td><td>${healthCell}</td><td>${driftCell}</td></tr>`;
|
|
483
|
+
}).join('\n');
|
|
484
|
+
const govSection = govRows
|
|
485
|
+
? `<h2>Governance / coverage gaps</h2>
|
|
486
|
+
<table border="1" cellpadding="4">
|
|
487
|
+
<tr><th>repo</th><th>scan health</th><th>policy drift</th></tr>
|
|
488
|
+
${govRows}
|
|
489
|
+
</table>`
|
|
490
|
+
: `<h2>Governance / coverage gaps</h2>
|
|
491
|
+
<p>Nothing to report — every scanned repo has a complete scan and no policy drift.</p>`;
|
|
492
|
+
// M4 §4.4: a SEPARATE section, same precedent as the governance section
|
|
493
|
+
// above — proven-origin debt is neither a risk-severity fact nor a
|
|
494
|
+
// governance-coverage fact, so it gets its own visual and structural space
|
|
495
|
+
// rather than being bolted onto either existing table. Lists the oldest
|
|
496
|
+
// finding PER REPO (not just the single fleet-wide oldest), so a reader can
|
|
497
|
+
// see which repos are carrying old proven debt, not only the single worst
|
|
498
|
+
// one.
|
|
499
|
+
const prov = rollup.provenance;
|
|
500
|
+
let provSection = '';
|
|
501
|
+
if (prov) {
|
|
502
|
+
// FR-PROV-019: the confidence column is not decoration — an age column
|
|
503
|
+
// with no confidence beside it is exactly the "age without its basis and
|
|
504
|
+
// confidence" the PRD forbids. The basis is the table's own framing
|
|
505
|
+
// (every row is proven-origin by construction); the confidence has to be
|
|
506
|
+
// shown per row, and an absent level reads "unknown" rather than blank,
|
|
507
|
+
// so a missing value can never be mistaken for a confident one.
|
|
508
|
+
const provRows = (prov.perRepoOldestProvenDebt || [])
|
|
509
|
+
.map(p => `<tr><td>${esc(p.repo)}</td><td>${esc(p.findingId ?? 'unknown')}</td><td>${esc(p.authorDate)}</td><td>${p.ageDays}</td><td>${esc(p.confidence ? String(p.confidence).toUpperCase() : 'unknown')}</td></tr>`)
|
|
510
|
+
.join('\n');
|
|
511
|
+
const provTable = provRows
|
|
512
|
+
? `<table border="1" cellpadding="4">
|
|
513
|
+
<tr><th>repo</th><th>finding</th><th>author date</th><th>age (days)</th><th>origin confidence</th></tr>
|
|
514
|
+
${provRows}
|
|
515
|
+
</table>`
|
|
516
|
+
: `<p>No complete-status (proven-origin) findings across the fleet.</p>`;
|
|
517
|
+
const noProvenNote = prov.reposWithNoProvenDebt.length
|
|
518
|
+
? `<p>${prov.reposWithNoProvenDebt.length} repo(s) with no complete-status provenance: ${prov.reposWithNoProvenDebt.map(esc).join(', ')}</p>`
|
|
519
|
+
: '';
|
|
520
|
+
const mttrByBasisRows = Object.entries(prov.mttr?.byAgeBasis || {})
|
|
521
|
+
.map(([basis, n]) => `<tr><td>${esc(basis)}</td><td>${n}</td></tr>`).join('\n');
|
|
522
|
+
const mttrByBasisTable = mttrByBasisRows
|
|
523
|
+
? `<table border="1" cellpadding="4">
|
|
524
|
+
<tr><th>age basis</th><th>count</th></tr>
|
|
525
|
+
${mttrByBasisRows}
|
|
526
|
+
</table>`
|
|
527
|
+
: '';
|
|
528
|
+
// Three distinct states, not two (final-review fix, item 4): `prov.mttr
|
|
529
|
+
// === null` means no scanned repo ever supplied a real mttr.js aggregate
|
|
530
|
+
// at all — the honest disclosure is "not available", never "no
|
|
531
|
+
// remediated findings recorded yet", which reads as a real zero
|
|
532
|
+
// measurement rather than an absent capability. A non-null `mttr` with
|
|
533
|
+
// `n === 0` is the legitimate zero: something DID track remediation and
|
|
534
|
+
// genuinely found none yet.
|
|
535
|
+
const mttrLine = !prov.mttr
|
|
536
|
+
? `<p>Fleet MTTR: not available — fleet mode does not yet track finding remediation history across runs.</p>`
|
|
537
|
+
: prov.mttr.n > 0
|
|
538
|
+
? `<p>Fleet MTTR: ${prov.mttr.n} remediated finding(s)${prov.mttr.meanDays != null ? `, mean ${Math.round(prov.mttr.meanDays)}d to remediate` : ''}.</p>${mttrByBasisTable}`
|
|
539
|
+
: `<p>Fleet MTTR: no remediated findings recorded yet.</p>`;
|
|
540
|
+
provSection = `<h2>Provenance-Proven Debt</h2>
|
|
541
|
+
${provTable}
|
|
542
|
+
${noProvenNote}
|
|
543
|
+
${mttrLine}`;
|
|
544
|
+
}
|
|
545
|
+
return `<!-- generated offline; no external resources -->
|
|
546
|
+
<h1>Fleet scan</h1>
|
|
547
|
+
<p>${esc(renderFleetSummary(rollup) || '')}</p>
|
|
548
|
+
<h2>Risk findings</h2>
|
|
549
|
+
<table border="1" cellpadding="4">
|
|
550
|
+
<tr><th>repo</th><th>total</th>${SEVERITIES.map(s => `<th>${s}</th>`).join('')}<th>proven</th><th>scanned at</th></tr>
|
|
551
|
+
${rows}
|
|
552
|
+
</table>
|
|
553
|
+
${govSection}
|
|
554
|
+
${provSection}`;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const _internals = { SCHEMA, SEVERITIES, _matchesGlob, _summarise, _provenanceDebtForRepo };
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
/***/ }),
|
|
561
|
+
|
|
562
|
+
/***/ 25620:
|
|
563
|
+
/***/ ((__webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
564
|
+
|
|
565
|
+
__webpack_require__.a(__webpack_module__, async (__webpack_handle_async_dependencies__, __webpack_async_result__) => { try {
|
|
566
|
+
__webpack_require__.r(__webpack_exports__);
|
|
567
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
568
|
+
/* harmony export */ cmdFleet: () => (/* binding */ cmdFleet),
|
|
569
|
+
/* harmony export */ parseFleetQuestion: () => (/* binding */ parseFleetQuestion)
|
|
570
|
+
/* harmony export */ });
|
|
571
|
+
/* harmony import */ var node_fs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(73024);
|
|
572
|
+
/* harmony import */ var node_path__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(76760);
|
|
573
|
+
/* harmony import */ var node_crypto__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(77598);
|
|
574
|
+
/* harmony import */ var _fleet_manifest_js__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(62790);
|
|
575
|
+
/* harmony import */ var _fleet_graph_js__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(94355);
|
|
576
|
+
/* harmony import */ var _fleet_js__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(80313);
|
|
577
|
+
/* harmony import */ var _state_dir_js__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(31174);
|
|
578
|
+
/* harmony import */ var _job_store_js__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(20121);
|
|
579
|
+
/* harmony import */ var _job_runner_js__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(89072);
|
|
580
|
+
/* harmony import */ var _org_health_js__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(75435);
|
|
581
|
+
/* harmony import */ var _discover_edges_js__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(89632);
|
|
582
|
+
/* harmony import */ var _enterprise_graph_js__WEBPACK_IMPORTED_MODULE_11__ = __webpack_require__(81630);
|
|
583
|
+
var __webpack_async_dependencies__ = __webpack_handle_async_dependencies__([_job_runner_js__WEBPACK_IMPORTED_MODULE_8__]);
|
|
584
|
+
_job_runner_js__WEBPACK_IMPORTED_MODULE_8__ = (__webpack_async_dependencies__.then ? (await __webpack_async_dependencies__)() : __webpack_async_dependencies__)[0];
|
|
585
|
+
// `agentic-security fleet init|scan|status|resume|query|explore`.
|
|
586
|
+
//
|
|
587
|
+
// The scanning half is the job fabric: a manifest becomes one idempotent job per repository in a local SQLite store, workers lease
|
|
588
|
+
// them, every scan runs in a supervised child under a hard timeout, and `resume` continues a run after anything stops it. The
|
|
589
|
+
// per-repository entry and the roll-up are the existing `posture/fleet.js` ones (counts, owners, scan health, delta), so a fleet
|
|
590
|
+
// report reads as it always has, with the organization's health and the enterprise graph added on top.
|
|
591
|
+
//
|
|
592
|
+
// Exit codes: 0 the organization is GREEN, 1 it is not (a required repository has no result, or a result is partial), 4 usage.
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
const GRAPH_FILE = 'fleet-graph.json';
|
|
607
|
+
const ENTERPRISE_FILE = node_path__WEBPACK_IMPORTED_MODULE_1__.join('fleet', 'enterprise-graph.json');
|
|
608
|
+
const DB_FILE = node_path__WEBPACK_IMPORTED_MODULE_1__.join('fleet', 'fleet.db');
|
|
609
|
+
const ROLLUP_FILE = 'fleet-rollup.json';
|
|
610
|
+
const sha = (s) => (0,node_crypto__WEBPACK_IMPORTED_MODULE_2__.createHash)('sha256').update(s).digest('hex');
|
|
611
|
+
|
|
612
|
+
const usage = () => { console.error('usage: fleet init | edges [--undeclared] | scan <manifest.yaml> [--workers N] [--retries N] [--timeout-seconds N | --timeout-ms N] [--no-cache] | status [run-id] | resume <run-id|manifest.yaml> [--retry-failed] | query (--depends-on <id> | --blast-radius <component> [--version v] | --internet-to <data-class> [--involving-vulnerable auth|<repo>] | "<question>") | explore [--out file.html]'); return 4; };
|
|
613
|
+
const isManifestArg = (s) => /\.ya?ml$/i.test(String(s ?? ''));
|
|
614
|
+
const manifestArg = (args) => { const m = args._[2]; return m && !String(m).startsWith('--') ? m : 'fleet.yaml'; };
|
|
615
|
+
const dbPath = (root) => (0,_state_dir_js__WEBPACK_IMPORTED_MODULE_6__.statePath)(root, DB_FILE);
|
|
616
|
+
|
|
617
|
+
async function cmdFleet(args, root) {
|
|
618
|
+
const sub = args._[1];
|
|
619
|
+
if (sub === 'init') return cmdInit(args, root);
|
|
620
|
+
if (sub === 'scan') return cmdScan(args, root);
|
|
621
|
+
if (sub === 'status') return cmdStatus(args, root);
|
|
622
|
+
if (sub === 'resume') return cmdResume(args, root);
|
|
623
|
+
if (sub === 'query') return cmdQuery(args, root);
|
|
624
|
+
if (sub === 'explore') return cmdExplore(args, root);
|
|
625
|
+
if (sub === 'edges') return cmdEdges(args, root);
|
|
626
|
+
return usage();
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** `fleet edges [--undeclared] [--json]`: every edge of the last enterprise graph with the evidence it rests on; --undeclared keeps the ones found only in code. */
|
|
630
|
+
async function cmdEdges(args, root) {
|
|
631
|
+
const f = (0,_state_dir_js__WEBPACK_IMPORTED_MODULE_6__.statePath)(root, ENTERPRISE_FILE);
|
|
632
|
+
if (!node_fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(f)) { console.error('No enterprise graph yet. Run `agentic-security fleet scan <manifest.yaml>` first.'); return 4; }
|
|
633
|
+
const g = JSON.parse(node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(f, 'utf8'));
|
|
634
|
+
const edges = g.edges.filter((e) => e.type !== 'exposes' && (!args.flags.undeclared || e.discovered));
|
|
635
|
+
if (args.flags.json) { console.log(JSON.stringify({ discovery: g.discovery ?? null, edges }, null, 2)); return 0; }
|
|
636
|
+
if (!edges.length) { console.log(args.flags.undeclared ? 'No edge was found in code that the manifest does not declare.' : 'The graph has no edges.'); }
|
|
637
|
+
for (const e of edges) {
|
|
638
|
+
const where = e.evidence.filter((x) => x.source === 'discovered-code-literal').map((x) => `${x.file}:${x.line}`);
|
|
639
|
+
console.log(`${e.from} -[${e.type}]-> ${e.to} ${e.discovered ? 'FOUND ONLY IN CODE' : where.length ? 'declared, and seen in code' : 'declared'}${where.length ? ` (${where.join(', ')})` : ''}`);
|
|
640
|
+
}
|
|
641
|
+
if (g.discovery?.limitations) console.log(`\nLimits: ${g.discovery.limitations.join(' ')}`);
|
|
642
|
+
else if (g.discovery?.note) console.log(`\nEdge discovery: ${g.discovery.note}.`);
|
|
643
|
+
return 0;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
async function cmdInit(args, root) {
|
|
647
|
+
const target = node_path__WEBPACK_IMPORTED_MODULE_1__.resolve(root, manifestArg(args) === 'fleet.yaml' ? 'fleet.yaml' : manifestArg(args));
|
|
648
|
+
if (node_fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(target) && !args.flags.force) { console.error(`${target} already exists. Pass --force to overwrite.`); return 4; }
|
|
649
|
+
node_fs__WEBPACK_IMPORTED_MODULE_0__.writeFileSync(target, (0,_fleet_manifest_js__WEBPACK_IMPORTED_MODULE_3__/* .starterManifestYaml */ .yr)(args.flags.organization || 'example'));
|
|
650
|
+
console.log(`Wrote ${target}. Edit it to list your repositories, then run: agentic-security fleet scan ${node_path__WEBPACK_IMPORTED_MODULE_1__.basename(target)}`);
|
|
651
|
+
return 0;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function overrides(manifest, args) {
|
|
655
|
+
const m = { ...manifest };
|
|
656
|
+
const num = (v, lo, hi) => { const n = Number(v); return Number.isInteger(n) && n >= lo && n <= hi ? n : null; };
|
|
657
|
+
if (args.flags.workers !== undefined) { const n = num(args.flags.workers, 1, 32); if (n == null) return { error: '--workers must be an integer from 1 to 32' }; m.workers = n; }
|
|
658
|
+
if (args.flags.retries !== undefined) { const n = num(args.flags.retries, 0, 5); if (n == null) return { error: '--retries must be an integer from 0 to 5' }; m.retries = n; }
|
|
659
|
+
if (args.flags['timeout-ms'] !== undefined) { const n = num(args.flags['timeout-ms'], 50, 86400000); if (n == null) return { error: '--timeout-ms must be an integer from 50 to 86400000' }; m.perRepoTimeoutMs = n; }
|
|
660
|
+
if (args.flags['timeout-seconds'] !== undefined) { const n = num(args.flags['timeout-seconds'], 1, 86400); if (n == null) return { error: '--timeout-seconds must be an integer from 1 to 86400' }; m.perRepoTimeoutMs = n * 1000; }
|
|
661
|
+
return { manifest: m };
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function entriesOf(artifacts, manifest) {
|
|
665
|
+
return manifest.repositories.filter((r) => artifacts.has(r.id)).map((r) => ({ ...artifacts.get(r.id).entry, repo: r.id }));
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
async function finishRun({ args, root, store, runId, manifest, manifestFile, interrupted }) {
|
|
669
|
+
const { artifacts, problems } = (0,_job_runner_js__WEBPACK_IMPORTED_MODULE_8__/* .loadArtifacts */ .kZ)({ store, runId, root });
|
|
670
|
+
const jobs = store.listJobs(runId);
|
|
671
|
+
const health = (0,_org_health_js__WEBPACK_IMPORTED_MODULE_9__/* .computeOrgHealth */ .WB)({ manifest, jobs });
|
|
672
|
+
for (const p of problems) { const r = health.repositories.find((x) => x.id === p.repoId); if (r) { r.state = 'failed'; r.error = p.problem; } }
|
|
673
|
+
if (problems.length) { health.counts.complete -= problems.filter((p) => health.repositories.find((x) => x.id === p.repoId)).length; health.counts.failed += problems.length; if (health.assurance !== 'red' && manifest.repositories.some((r) => r.required && problems.some((p) => p.repoId === r.id))) health.assurance = 'red'; health.reasons.push(`${problems.length} result(s) could not be verified: ${problems.map((p) => `${p.repoId} (${p.problem})`).join(', ')}`); }
|
|
674
|
+
|
|
675
|
+
let previous = null;
|
|
676
|
+
const rollupFile = (0,_state_dir_js__WEBPACK_IMPORTED_MODULE_6__.statePath)(root, ROLLUP_FILE);
|
|
677
|
+
try { previous = JSON.parse(node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(rollupFile, 'utf8')); } catch { /* first run */ }
|
|
678
|
+
const rollup = (0,_fleet_js__WEBPACK_IMPORTED_MODULE_5__/* .rollupFleet */ .JQ)(entriesOf(artifacts, manifest).concat(health.repositories.filter((r) => !artifacts.has(r.id) && r.state !== 'complete' && r.state !== 'partial').map((r) => ({ repo: r.id, ok: false, error: r.error || r.state, total: null, bySeverity: null, ids: [], scannedAt: new Date().toISOString(), scanHealth: null, policyDrift: null, provenanceDebt: null, mttr: null }))), previous);
|
|
679
|
+
(0,_state_dir_js__WEBPACK_IMPORTED_MODULE_6__.safeWriteState)(rollupFile, JSON.stringify(rollup, null, 2));
|
|
680
|
+
|
|
681
|
+
const dep = (0,_fleet_graph_js__WEBPACK_IMPORTED_MODULE_4__/* .buildFleetDependencyGraph */ .u_)(manifest.repositories);
|
|
682
|
+
(0,_state_dir_js__WEBPACK_IMPORTED_MODULE_6__.safeWriteState)((0,_state_dir_js__WEBPACK_IMPORTED_MODULE_6__.statePath)(root, GRAPH_FILE), JSON.stringify({ organization: manifest.organization, builtAt: new Date().toISOString(), ...dep }, null, 2));
|
|
683
|
+
// edges the code implies; skipped with --no-discover. A failure here never fails the run: the graph is then declared-only and says so.
|
|
684
|
+
let discovered = null; let discoveryNote = args.flags['no-discover'] ? 'skipped (--no-discover)' : null;
|
|
685
|
+
if (!args.flags['no-discover']) { try { discovered = (0,_discover_edges_js__WEBPACK_IMPORTED_MODULE_10__/* .discoverEdges */ .U)({ manifest }); } catch (e) { discoveryNote = `failed (${String(e.message).slice(0, 80)}); the graph holds declared edges only`; } }
|
|
686
|
+
const split = discovered ? (0,_discover_edges_js__WEBPACK_IMPORTED_MODULE_10__/* .splitDeclared */ .Z)(discovered, manifest) : null;
|
|
687
|
+
const graph = (0,_enterprise_graph_js__WEBPACK_IMPORTED_MODULE_11__/* .finalizeGraph */ .Th)((0,_enterprise_graph_js__WEBPACK_IMPORTED_MODULE_11__/* .buildEnterpriseGraph */ .Ws)({ manifest, dependencyGraph: dep, discovered }));
|
|
688
|
+
const findingsByRepo = Object.fromEntries([...artifacts].map(([id, a]) => [id, a.findings ?? []]));
|
|
689
|
+
(0,_state_dir_js__WEBPACK_IMPORTED_MODULE_6__.safeWriteState)((0,_state_dir_js__WEBPACK_IMPORTED_MODULE_6__.statePath)(root, ENTERPRISE_FILE), JSON.stringify({ ...graph, runId, manifestFile, findingsByRepo, discovery: discovered ? { scanned: discovered.scanned, limitations: discovered.limitations, undeclared: split.undeclared.length, confirmed: split.confirmed.length } : { note: discoveryNote }, builtAt: new Date().toISOString() }));
|
|
690
|
+
|
|
691
|
+
if (args.flags.json) console.log(JSON.stringify({ runId, health, rollup }, null, 2));
|
|
692
|
+
else {
|
|
693
|
+
console.log((0,_fleet_js__WEBPACK_IMPORTED_MODULE_5__/* .renderFleetSummary */ .LL)(rollup));
|
|
694
|
+
console.log(`\nRun: ${runId}\n${(0,_org_health_js__WEBPACK_IMPORTED_MODULE_9__/* .renderOrgHealth */ .Vp)(health)}`);
|
|
695
|
+
if (split) console.log(`\nEdges found in code: ${split.undeclared.length} not in the manifest, ${split.confirmed.length} matching a declared edge. ${split.undeclared.length ? 'List them with: agentic-security fleet edges --undeclared' : ''}`.trimEnd());
|
|
696
|
+
else if (discoveryNote) console.log(`\nEdge discovery ${discoveryNote}.`);
|
|
697
|
+
if (interrupted) console.log(`\nInterrupted. Continue with: agentic-security fleet resume ${runId}`);
|
|
698
|
+
else if (health.counts.failed || health.counts.timedOut || health.counts.notRun) console.log(`\nRetry the failed and timed-out repositories with: agentic-security fleet resume ${runId} --retry-failed`);
|
|
699
|
+
}
|
|
700
|
+
return health.assurance === 'green' && !interrupted ? 0 : 1;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
async function runAll({ args, root, store, runId, manifest, manifestFile }) {
|
|
704
|
+
const ac = new AbortController(); const onSig = () => ac.abort(); process.once('SIGINT', onSig); process.once('SIGTERM', onSig);
|
|
705
|
+
try {
|
|
706
|
+
await (0,_job_runner_js__WEBPACK_IMPORTED_MODULE_8__/* .runJobs */ .Ag)({ manifest, store, runId, root, scan: _job_runner_js__WEBPACK_IMPORTED_MODULE_8__/* .childScan */ .IT, signal: ac.signal, useCache: !args.flags['no-cache'], onEvent: (e) => {
|
|
707
|
+
if (args.flags.json) return;
|
|
708
|
+
if (e.type === 'done') console.error(`[fleet] done: ${e.repo}${e.cached ? ' (cached)' : ''}${e.status === 'partial' ? ' (partial)' : ''}`);
|
|
709
|
+
else if (e.type === 'failed') console.error(`[fleet] ${e.timedOut ? 'TIMED OUT' : 'FAILED'}: ${e.repo} (${e.error})${e.next === 'queued' ? ', will retry' : ''}`);
|
|
710
|
+
else if (e.type === 'fenced') console.error(`[fleet] ${e.repo}: result discarded, another worker holds the job`);
|
|
711
|
+
} });
|
|
712
|
+
return await finishRun({ args, root, store, runId, manifest, manifestFile, interrupted: ac.signal.aborted });
|
|
713
|
+
} finally { process.off('SIGINT', onSig); process.off('SIGTERM', onSig); }
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
async function cmdScan(args, root) {
|
|
717
|
+
const manifestFile = node_path__WEBPACK_IMPORTED_MODULE_1__.resolve(root, manifestArg(args));
|
|
718
|
+
const loaded = (0,_fleet_manifest_js__WEBPACK_IMPORTED_MODULE_3__/* .loadFleetManifest */ .Cb)(manifestFile);
|
|
719
|
+
if (!loaded.ok) { console.error(`Invalid fleet manifest: ${loaded.errors.join('; ')}`); return 4; }
|
|
720
|
+
const o = overrides(loaded.manifest, args);
|
|
721
|
+
if (o.error) { console.error(o.error); return 4; }
|
|
722
|
+
const { manifest } = o;
|
|
723
|
+
if (!(0,_state_dir_js__WEBPACK_IMPORTED_MODULE_6__.ensureStateDir)(root)) { console.error('No safe state directory for this project.'); return 4; }
|
|
724
|
+
const store = await (0,_job_store_js__WEBPACK_IMPORTED_MODULE_7__/* .createSqliteJobStore */ .J6)({ file: dbPath(root) });
|
|
725
|
+
try {
|
|
726
|
+
const manifestHash = sha(node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(manifestFile));
|
|
727
|
+
console.error(`[fleet] planning ${manifest.repositories.length} repository(ies) for ${manifest.organization} (${manifest.workers} worker(s), ${manifest.retries} retr${manifest.retries === 1 ? 'y' : 'ies'}, ${Math.round(manifest.perRepoTimeoutMs / 1000)}s per repository)...`);
|
|
728
|
+
const plan = (0,_job_runner_js__WEBPACK_IMPORTED_MODULE_8__/* .planRun */ .nY)({ manifest, manifestHash, store, meta: { manifestFile } });
|
|
729
|
+
return await runAll({ args, root, store, runId: plan.runId, manifest, manifestFile });
|
|
730
|
+
} finally { store.close(); }
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
async function cmdResume(args, root) {
|
|
734
|
+
const target = String(args._[2] ?? '');
|
|
735
|
+
if (!target) return usage();
|
|
736
|
+
const dbFile = dbPath(root);
|
|
737
|
+
if (!node_fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(dbFile)) { console.error('No fleet run exists in this project. Start one with: agentic-security fleet scan <manifest.yaml>'); return 4; }
|
|
738
|
+
const store = await (0,_job_store_js__WEBPACK_IMPORTED_MODULE_7__/* .createSqliteJobStore */ .J6)({ file: dbFile });
|
|
739
|
+
try {
|
|
740
|
+
let run;
|
|
741
|
+
if (isManifestArg(target)) {
|
|
742
|
+
const hash = sha(node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(node_path__WEBPACK_IMPORTED_MODULE_1__.resolve(root, target)));
|
|
743
|
+
run = store.listRuns().find((r) => r.manifestHash === hash && store.listJobs(r.runId).some((j) => ['queued', 'leased'].includes(j.status)));
|
|
744
|
+
if (!run) { console.error('Nothing to resume for that manifest: no run of it has unfinished work. Use `fleet resume <run-id> --retry-failed` to retry failed repositories.'); return 4; }
|
|
745
|
+
} else run = store.getRun(target);
|
|
746
|
+
if (!run) { console.error(`No run ${target}. Runs: ${store.listRuns().slice(0, 5).map((r) => r.runId).join(', ') || 'none'}`); return 4; }
|
|
747
|
+
const full = store.getRun(run.runId);
|
|
748
|
+
const manifestFile = node_path__WEBPACK_IMPORTED_MODULE_1__.resolve(root, args.flags.manifest ?? full.meta?.manifestFile ?? (isManifestArg(target) ? target : 'fleet.yaml'));
|
|
749
|
+
const loaded = (0,_fleet_manifest_js__WEBPACK_IMPORTED_MODULE_3__/* .loadFleetManifest */ .Cb)(manifestFile);
|
|
750
|
+
if (!loaded.ok) { console.error(`Invalid fleet manifest: ${loaded.errors.join('; ')}`); return 4; }
|
|
751
|
+
const hash = sha(node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(manifestFile));
|
|
752
|
+
if (hash !== full.manifestHash) { console.error(`The manifest ${node_path__WEBPACK_IMPORTED_MODULE_1__.basename(manifestFile)} has changed since run ${full.runId} was planned. A resumed run must scan what it planned; start a new scan instead.`); return 4; }
|
|
753
|
+
const o = overrides(loaded.manifest, args);
|
|
754
|
+
if (o.error) { console.error(o.error); return 4; }
|
|
755
|
+
const freed = (0,_job_runner_js__WEBPACK_IMPORTED_MODULE_8__/* .reclaimDeadWorkers */ .zD)(store, full.runId) + store.reclaimExpired(full.runId, { now: Date.now() });
|
|
756
|
+
const requeued = args.flags['retry-failed'] ? store.requeueTerminal(full.runId, ['failed', 'timed_out']) : 0;
|
|
757
|
+
// a limit given on the command line applies to the work still to do, including the work just re-queued
|
|
758
|
+
if (args.flags['timeout-ms'] !== undefined || args.flags['timeout-seconds'] !== undefined) store.retime(full.runId, o.manifest.perRepoTimeoutMs);
|
|
759
|
+
const open = store.listJobs(full.runId).filter((j) => ['queued', 'leased'].includes(j.status)).length;
|
|
760
|
+
console.error(`[fleet] resuming ${full.runId}: ${open} unfinished, ${freed} lease(s) recovered${requeued ? `, ${requeued} failed repository(ies) re-queued` : ''}; finished results are reused.`);
|
|
761
|
+
return await runAll({ args, root, store, runId: full.runId, manifest: o.manifest, manifestFile });
|
|
762
|
+
} finally { store.close(); }
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
async function cmdStatus(args, root) {
|
|
766
|
+
const dbFile = dbPath(root);
|
|
767
|
+
if (!node_fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(dbFile)) {
|
|
768
|
+
const f = (0,_state_dir_js__WEBPACK_IMPORTED_MODULE_6__.statePath)(root, ROLLUP_FILE);
|
|
769
|
+
if (!node_fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(f)) { console.error('No fleet scan has been run yet. Run `agentic-security fleet scan <manifest.yaml>` first.'); return 4; }
|
|
770
|
+
const rollup = JSON.parse(node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(f, 'utf8'));
|
|
771
|
+
console.log(args.flags.json ? JSON.stringify(rollup, null, 2) : (0,_fleet_js__WEBPACK_IMPORTED_MODULE_5__/* .renderFleetSummary */ .LL)(rollup));
|
|
772
|
+
return 0;
|
|
773
|
+
}
|
|
774
|
+
const store = await (0,_job_store_js__WEBPACK_IMPORTED_MODULE_7__/* .createSqliteJobStore */ .J6)({ file: dbFile });
|
|
775
|
+
try {
|
|
776
|
+
const arg = args._[2];
|
|
777
|
+
const run = arg && !isManifestArg(arg) ? store.getRun(String(arg)) : store.listRuns()[0] && store.getRun(store.listRuns()[0].runId);
|
|
778
|
+
if (!run) { console.error(arg && !isManifestArg(arg) ? `No run ${arg}.` : 'No fleet run exists yet.'); return 4; }
|
|
779
|
+
const meta = run.meta ?? {};
|
|
780
|
+
const manifest = (0,_fleet_manifest_js__WEBPACK_IMPORTED_MODULE_3__/* .loadFleetManifest */ .Cb)(node_path__WEBPACK_IMPORTED_MODULE_1__.resolve(root, meta.manifestFile ?? 'fleet.yaml'));
|
|
781
|
+
const jobs = store.listJobs(run.runId);
|
|
782
|
+
const m = manifest.ok ? manifest.manifest : { repositories: jobs.map((j) => ({ id: j.repoId, required: j.required })) };
|
|
783
|
+
const health = (0,_org_health_js__WEBPACK_IMPORTED_MODULE_9__/* .computeOrgHealth */ .WB)({ manifest: m, jobs });
|
|
784
|
+
if (args.flags.json) { console.log(JSON.stringify({ runId: run.runId, health, jobs: jobs.map((j) => ({ repo: j.repoId, status: j.status, attempts: j.attempts, worker: j.worker, leaseExpiresAt: j.leaseExpiresAt, error: j.error })) }, null, 2)); return 0; }
|
|
785
|
+
console.log(`Run: ${run.runId} (${run.organization})\n${(0,_org_health_js__WEBPACK_IMPORTED_MODULE_9__/* .renderOrgHealth */ .Vp)(health)}`);
|
|
786
|
+
const live = jobs.filter((j) => j.status === 'leased');
|
|
787
|
+
if (live.length) console.log(`\nIn progress: ${live.map((j) => `${j.repoId} (attempt ${j.attempts}, lease until ${new Date(j.leaseExpiresAt).toISOString()})`).join(', ')}`);
|
|
788
|
+
const f = (0,_state_dir_js__WEBPACK_IMPORTED_MODULE_6__.statePath)(root, ROLLUP_FILE);
|
|
789
|
+
if (node_fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(f)) console.log(`\n${(0,_fleet_js__WEBPACK_IMPORTED_MODULE_5__/* .renderFleetSummary */ .LL)(JSON.parse(node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(f, 'utf8')))}`);
|
|
790
|
+
return 0;
|
|
791
|
+
} finally { store.close(); }
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// "Show every Internet-facing path to PCI data involving vulnerable authentication code" is recognised as a fixed shape and mapped
|
|
795
|
+
// to the structured query below. Anything else is refused with the flags to use; this is not a natural-language engine.
|
|
796
|
+
function parseFleetQuestion(text) {
|
|
797
|
+
const m = /^\s*show\s+every\s+internet-facing\s+path\s+to\s+([a-z][a-z0-9-]*)\s+data(?:\s+involving\s+vulnerable\s+([a-z][a-z0-9-]*)\s+(?:code|components?|librar(?:y|ies)))?\s*\.?\s*$/i.exec(String(text ?? ''));
|
|
798
|
+
if (!m) return null;
|
|
799
|
+
const word = m[2]?.toLowerCase() ?? null;
|
|
800
|
+
return { toDataClass: m[1].toLowerCase(), involving: word ? { family: word === 'authentication' || word === 'authorization' ? 'auth' : word } : null };
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function readEnterprise(root) {
|
|
804
|
+
const f = (0,_state_dir_js__WEBPACK_IMPORTED_MODULE_6__.statePath)(root, ENTERPRISE_FILE);
|
|
805
|
+
if (!node_fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(f)) return null;
|
|
806
|
+
try { return JSON.parse(node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(f, 'utf8')); } catch { return null; }
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
async function cmdQuery(args, root) {
|
|
810
|
+
const flags = args.flags;
|
|
811
|
+
const question = args._[2] && !isManifestArg(args._[2]) ? String(args._[2]) : null;
|
|
812
|
+
const parsed = question ? parseFleetQuestion(question) : null;
|
|
813
|
+
if (question && !parsed) { console.error(`Not a recognised question. This command answers one shape: "Show every Internet-facing path to <data class> data [involving vulnerable <authentication|family> code]". For anything else use --depends-on, --blast-radius, or --internet-to with --involving-vulnerable.`); return 4; }
|
|
814
|
+
|
|
815
|
+
if (flags['depends-on']) {
|
|
816
|
+
const f = (0,_state_dir_js__WEBPACK_IMPORTED_MODULE_6__.statePath)(root, GRAPH_FILE);
|
|
817
|
+
if (!node_fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(f)) { console.error('No fleet dependency graph yet. Run `agentic-security fleet scan <manifest.yaml>` first.'); return 4; }
|
|
818
|
+
const graph = JSON.parse(node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(f, 'utf8'));
|
|
819
|
+
const target = String(flags['depends-on']);
|
|
820
|
+
if (!graph.nodes.some((n) => n.id === target)) { console.error(`${target} is not a repository in the last fleet scan.`); return 4; }
|
|
821
|
+
const radius = (0,_fleet_graph_js__WEBPACK_IMPORTED_MODULE_4__/* .blastRadius */ .X2)(graph, target);
|
|
822
|
+
if (flags.json) { console.log(JSON.stringify({ target, blastRadius: radius }, null, 2)); return 0; }
|
|
823
|
+
if (!radius.length) { console.log(`No fleet repository depends on ${target} (by declared package name).`); return 0; }
|
|
824
|
+
console.log(`Repositories depending on ${target} (directly or transitively):`);
|
|
825
|
+
for (const r of radius) console.log(` ${r.id} (depth ${r.depth}, via ${r.path.slice(1).join(' -> ')})`);
|
|
826
|
+
return 0;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const ent = readEnterprise(root);
|
|
830
|
+
if (!ent) { console.error('No enterprise graph yet. Run `agentic-security fleet scan <manifest.yaml>` first.'); return 4; }
|
|
831
|
+
const bad = (0,_enterprise_graph_js__WEBPACK_IMPORTED_MODULE_11__/* .validateEnterpriseGraph */ .cl)(ent);
|
|
832
|
+
if (bad.length) { console.error(`The stored enterprise graph is inconsistent (${bad[0]}). Re-run the scan.`); return 1; }
|
|
833
|
+
const findings = new Map(Object.entries(ent.findingsByRepo ?? {}));
|
|
834
|
+
|
|
835
|
+
if (flags['blast-radius']) {
|
|
836
|
+
const r = (0,_enterprise_graph_js__WEBPACK_IMPORTED_MODULE_11__/* .componentBlastRadius */ ._o)({ graph: ent, component: String(flags['blast-radius']), version: flags.version ? String(flags.version) : null });
|
|
837
|
+
if (!r) { console.error(`${flags['blast-radius']} is not in the last fleet scan.`); return 4; }
|
|
838
|
+
console.log(flags.json ? JSON.stringify(r, null, 2) : (0,_enterprise_graph_js__WEBPACK_IMPORTED_MODULE_11__/* .renderBlastRadius */ .lB)(r));
|
|
839
|
+
return 0;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const toClass = parsed?.toDataClass ?? flags['internet-to'];
|
|
843
|
+
if (toClass) {
|
|
844
|
+
const sel = parsed?.involving ?? (flags['involving-vulnerable'] ? (/^[a-z][a-z0-9-]*$/.test(String(flags['involving-vulnerable'])) && ent.nodes.some((n) => n.id === flags['involving-vulnerable']) ? { repo: String(flags['involving-vulnerable']) } : { family: String(flags['involving-vulnerable']) }) : null);
|
|
845
|
+
const involving = sel ? (0,_enterprise_graph_js__WEBPACK_IMPORTED_MODULE_11__/* .vulnerableSet */ .r8)({ graph: ent, findingsByRepo: findings, selector: sel }) : null;
|
|
846
|
+
const res = (0,_enterprise_graph_js__WEBPACK_IMPORTED_MODULE_11__/* .attackPaths */ .Nn)({ graph: ent, toDataClass: String(toClass).toLowerCase(), involving });
|
|
847
|
+
if (flags.json) { console.log(JSON.stringify({ ...res, involving: involving ? { direct: [...involving.direct], all: [...involving.all], why: Object.fromEntries(involving.why) } : null }, null, 2)); return 0; }
|
|
848
|
+
if (!res.targets.length) { console.log(`No node in the fleet manifest is marked as holding ${toClass} data, so there is nothing to reach.`); return 0; }
|
|
849
|
+
if (!res.paths.length) console.log(`No Internet-facing path to ${toClass} data${involving ? ' involving that vulnerable code' : ''} was found in the declared graph.${res.truncated ? ' (search bound reached)' : ''}`);
|
|
850
|
+
for (const p of res.paths) {
|
|
851
|
+
console.log(p.nodes.map((n) => (n === _enterprise_graph_js__WEBPACK_IMPORTED_MODULE_11__/* .INTERNET */ .aw ? 'Internet' : n)).join(' -> '));
|
|
852
|
+
if (p.discoveredEdges?.length) console.log(` rests on edge(s) found only in code, not declared: ${p.discoveredEdges.join(', ')}`);
|
|
853
|
+
if (p.involving.length) console.log(` involves vulnerable code: ${p.involving.map((n) => `${n} (${involving.why.get(n)})`).join(', ')}`);
|
|
854
|
+
}
|
|
855
|
+
if (res.paths.length) console.log(`\n${res.paths.length} path(s). Reach edges are as declared in the fleet manifest, plus any marked above as found only in code.${res.truncated ? ' The search bound was reached; more may exist.' : ''}`);
|
|
856
|
+
return 0;
|
|
857
|
+
}
|
|
858
|
+
return usage();
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
862
|
+
async function cmdExplore(args, root) {
|
|
863
|
+
const ent = readEnterprise(root);
|
|
864
|
+
if (!ent) { console.error('No enterprise graph yet. Run `agentic-security fleet scan <manifest.yaml>` first.'); return 4; }
|
|
865
|
+
const rows = ent.edges.map((e) => `<tr><td>${esc(e.from)}</td><td>${esc(e.type)}${e.reach ? '' : ' (dependency)'}</td><td>${esc(e.to)}</td><td>${e.evidence.map((x) => esc(`${x.source}: ${x.repo ?? ''} ${x.field ?? x.declared ?? ''} ${x.value ?? x.spec ?? ''}`.trim())).join('<br>')}</td></tr>`).join('\n');
|
|
866
|
+
const nodes = ent.nodes.map((n) => `<tr><td>${esc(n.id)}</td><td>${esc(n.type)}</td><td>${n.internetFacing ? 'yes' : ''}</td><td>${esc(n.environment ?? '')}</td><td>${esc(n.dataClasses.join(', '))}</td><td>${esc(n.trustZone ?? '')}</td></tr>`).join('\n');
|
|
867
|
+
const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Fleet graph</title><style>body{font:14px/1.5 system-ui,sans-serif;max-width:70rem;margin:2rem auto;padding:0 1rem}table{border-collapse:collapse;margin:.5rem 0 2rem;width:100%}td,th{border:1px solid #8884;padding:.25rem .6rem;text-align:left;vertical-align:top}</style></head><body><h1>${esc(ent.organization)} fleet graph</h1><p>Run ${esc(ent.runId)}. Reach edges are as declared in the fleet manifest; dependency edges come from package manifests. Every edge lists its evidence.</p><h2>Nodes</h2><table><tr><th>id</th><th>type</th><th>internet-facing</th><th>environment</th><th>data</th><th>trust zone</th></tr>${nodes}</table><h2>Edges</h2><table><tr><th>from</th><th>type</th><th>to</th><th>evidence</th></tr>${rows}</table></body></html>\n`;
|
|
868
|
+
const out = node_path__WEBPACK_IMPORTED_MODULE_1__.resolve(root, String(args.flags.out ?? 'fleet-graph.html'));
|
|
869
|
+
node_fs__WEBPACK_IMPORTED_MODULE_0__.writeFileSync(out, html);
|
|
870
|
+
console.log(`Wrote ${out} (${ent.nodes.length} nodes, ${ent.edges.length} edges). A static page, no server.`);
|
|
871
|
+
return 0;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
__webpack_async_result__();
|
|
875
|
+
} catch(e) { __webpack_async_result__(e); } });
|
|
876
|
+
|
|
877
|
+
/***/ }),
|
|
878
|
+
|
|
879
|
+
/***/ 89632:
|
|
880
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
881
|
+
|
|
882
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
883
|
+
/* harmony export */ U: () => (/* binding */ discoverEdges),
|
|
884
|
+
/* harmony export */ Z: () => (/* binding */ splitDeclared)
|
|
885
|
+
/* harmony export */ });
|
|
886
|
+
/* harmony import */ var node_fs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(73024);
|
|
887
|
+
/* harmony import */ var node_path__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(76760);
|
|
888
|
+
/* harmony import */ var _state_dir_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(31174);
|
|
889
|
+
// Edges the code implies but the manifest does not declare (PRD 4: the enterprise graph should not be only what someone wrote down).
|
|
890
|
+
//
|
|
891
|
+
// What this does: reads the text of each repository's source and configuration files and looks for a few exact, checkable
|
|
892
|
+
// patterns that name ANOTHER node of the manifest:
|
|
893
|
+
// * a URL literal whose host IS a node's id or alias, or whose first label is (payments-api matches payments-api.svc.cluster.local);
|
|
894
|
+
// * a queue/topic resource's name or alias as a string literal on a line that publishes to it, or on one that consumes from it;
|
|
895
|
+
// * a database resource's name or alias on a line that opens a connection.
|
|
896
|
+
// Each hit becomes an edge of type `calls`, `produces`, `consumes` or `connects-to` with the file and line that produced it, tagged
|
|
897
|
+
// `source: discovered-code-literal`, and is reported separately from declared edges: a discovered edge never replaces or hides a
|
|
898
|
+
// declared one, and a query result says which of its edges were only discovered.
|
|
899
|
+
//
|
|
900
|
+
// What it cannot do, and says so: it matches whole tokens, not substrings (so `orders` never matches `reorders`); it sees literals, not
|
|
901
|
+
// values built at run time, environment-supplied URLs or service-mesh routing; it cannot tell the direction of a database use; it
|
|
902
|
+
// skips comment lines but does not parse code, so a literal in a string that is never executed still counts. A discovered edge is a
|
|
903
|
+
// lead with evidence, not a fact. It is bounded in files, bytes and time.
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
const DISCOVERY_LIMITS = Object.freeze({ maxFilesPerRepo: 2000, maxFileBytes: 512 * 1024, maxTotalBytes: 64 * 1024 * 1024, maxEvidencePerEdge: 3 });
|
|
909
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'vendor', '.venv', 'venv', '__pycache__', 'target', _state_dir_js__WEBPACK_IMPORTED_MODULE_2__/* .STATE_DIR_NAME */ .Ky, 'coverage', '.next', 'dist-newstyle', '.stack-work']);
|
|
910
|
+
const TEXT_EXT = /\.(?:js|mjs|cjs|jsx|ts|tsx|py|java|kt|go|rb|php|cs|rs|scala|ya?ml|json|toml|properties|env|tf|conf|ini|hs|nix)$/i;
|
|
911
|
+
const COMMENT = /^\s*(?:\/\/|#|\*|\/\*|--|;)/;
|
|
912
|
+
const URL_RE = /\b[a-z][a-z0-9+.-]{1,12}:\/\/[^\s'"`<>)\]}]{1,300}/gi;
|
|
913
|
+
const PUBLISH = /\b(?:send_?message|publish|produce|send_?to_?queue|enqueue|put_?record|xadd|lpush|rpush|sendmessage|sendtoqueue|emit_?to)\b/i;
|
|
914
|
+
const CONSUME = /\b(?:consume|subscribe|receive_?message|poll|on_?message|get_?messages?|basic_?consume|xread|brpop|blpop|receivemessage)\b/i;
|
|
915
|
+
const CONNECT = /(?:\b(?:connect|create_?connection|createpool|new\s+pool|mongoclient|drivermanager|sqlalchemy|create_engine|database_url|dbname)\b|jdbc:|postgres(?:ql)?:\/\/|mysql:\/\/|mongodb(?:\+srv)?:\/\/|redis:\/\/)/i;
|
|
916
|
+
|
|
917
|
+
/** The names a node answers to: its id plus declared aliases, lower-case. */
|
|
918
|
+
function namesOf(node) { return [node.id, ...(node.aliases ?? [])].map((x) => String(x).toLowerCase()); }
|
|
919
|
+
|
|
920
|
+
function* sourceFiles(root, limits) {
|
|
921
|
+
let count = 0; let bytes = 0;
|
|
922
|
+
const stack = ['']; const rootReal = node_fs__WEBPACK_IMPORTED_MODULE_0__.realpathSync(root);
|
|
923
|
+
while (stack.length) {
|
|
924
|
+
const rel = stack.pop();
|
|
925
|
+
let ents; try { ents = node_fs__WEBPACK_IMPORTED_MODULE_0__.readdirSync(node_path__WEBPACK_IMPORTED_MODULE_1__.join(root, rel), { withFileTypes: true }); } catch { continue; }
|
|
926
|
+
for (const e of ents.sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
927
|
+
if (e.isSymbolicLink()) continue;
|
|
928
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
929
|
+
if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) stack.push(r); continue; }
|
|
930
|
+
if (!e.isFile() || !TEXT_EXT.test(e.name)) continue;
|
|
931
|
+
let st; try { st = node_fs__WEBPACK_IMPORTED_MODULE_0__.statSync(node_path__WEBPACK_IMPORTED_MODULE_1__.join(root, r)); } catch { continue; }
|
|
932
|
+
if (st.size > limits.maxFileBytes) continue;
|
|
933
|
+
if (++count > limits.maxFilesPerRepo || (bytes += st.size) > limits.maxTotalBytes) return;
|
|
934
|
+
let real; try { real = node_fs__WEBPACK_IMPORTED_MODULE_0__.realpathSync(node_path__WEBPACK_IMPORTED_MODULE_1__.join(root, r)); } catch { continue; }
|
|
935
|
+
if (!real.startsWith(rootReal + node_path__WEBPACK_IMPORTED_MODULE_1__.sep)) continue;
|
|
936
|
+
yield { rel: r, abs: real };
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
const stringLiterals = (line) => [...line.matchAll(/(['"`])((?:\\.|(?!\1).){1,200})\1/g)].map((m) => m[2]);
|
|
942
|
+
const tokenIn = (haystackLiteral, name) => new RegExp(`(?:^|[^a-z0-9_-])${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:$|[^a-z0-9_-])`, 'i').test(haystackLiteral);
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* @param {{manifest: object, limits?: object}} o
|
|
946
|
+
* @returns {{edges: Array<{type: string, from: string, to: string, evidence: object[]}>, scanned: Record<string, {files: number, truncated: boolean}>, limitations: string[]}}
|
|
947
|
+
*/
|
|
948
|
+
function discoverEdges({ manifest, limits = DISCOVERY_LIMITS }) {
|
|
949
|
+
const L = { ...DISCOVERY_LIMITS, ...limits };
|
|
950
|
+
const repoNodes = manifest.repositories.map((r) => ({ id: r.id, aliases: r.aliases ?? [], kind: 'repo' }));
|
|
951
|
+
const resNodes = (manifest.resources ?? []).map((r) => ({ id: r.id, aliases: r.aliases ?? [], kind: r.kind }));
|
|
952
|
+
const found = new Map(); const scanned = {};
|
|
953
|
+
const add = (type, from, to, ev) => {
|
|
954
|
+
const key = `${type}\u0000${from}\u0000${to}`;
|
|
955
|
+
const e = found.get(key) ?? { type, from, to, evidence: [] };
|
|
956
|
+
if (e.evidence.length < L.maxEvidencePerEdge) e.evidence.push({ source: 'discovered-code-literal', repo: from, declared: false, ...ev });
|
|
957
|
+
found.set(key, e);
|
|
958
|
+
};
|
|
959
|
+
for (const repo of manifest.repositories) {
|
|
960
|
+
let files = 0; let truncated = false;
|
|
961
|
+
const targets = [...repoNodes.filter((n) => n.id !== repo.id), ...resNodes];
|
|
962
|
+
const it = sourceFiles(repo.path, L);
|
|
963
|
+
for (;;) {
|
|
964
|
+
const nx = it.next();
|
|
965
|
+
if (nx.done) break;
|
|
966
|
+
files++;
|
|
967
|
+
let text; try { text = node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(nx.value.abs, 'utf8'); } catch { continue; }
|
|
968
|
+
if (text.includes('\0')) continue;
|
|
969
|
+
const lines = text.split('\n');
|
|
970
|
+
for (let i = 0; i < lines.length; i++) {
|
|
971
|
+
const line = lines[i];
|
|
972
|
+
if (line.length > 2000 || COMMENT.test(line)) continue;
|
|
973
|
+
// 1. URL literals naming a repository or gateway
|
|
974
|
+
for (const m of line.matchAll(URL_RE)) {
|
|
975
|
+
let host; try { host = new URL(m[0].replace(/[.,;]+$/, '')).hostname.toLowerCase(); } catch { continue; }
|
|
976
|
+
const first = host.split('.')[0];
|
|
977
|
+
for (const n of targets) {
|
|
978
|
+
if (n.kind !== 'repo' && n.kind !== 'gateway' && n.kind !== 'external-provider') continue;
|
|
979
|
+
if (namesOf(n).some((nm) => nm === host || nm === first)) add(n.kind === 'external-provider' ? 'uses-provider' : 'calls', repo.id, n.id, { file: nx.value.rel, line: i + 1, host });
|
|
980
|
+
}
|
|
981
|
+
for (const n of targets) if (n.kind === 'database' && namesOf(n).some((nm) => nm === host || nm === host.split('.')[0]) && CONNECT.test(m[0])) add('connects-to', repo.id, n.id, { file: nx.value.rel, line: i + 1, host });
|
|
982
|
+
}
|
|
983
|
+
// 2. queue and database names as literals on a publishing / consuming / connecting line
|
|
984
|
+
const lits = stringLiterals(line);
|
|
985
|
+
if (!lits.length) continue;
|
|
986
|
+
for (const n of targets) {
|
|
987
|
+
if (n.kind === 'queue') {
|
|
988
|
+
if (!lits.some((l) => namesOf(n).some((nm) => tokenIn(l, nm)))) continue;
|
|
989
|
+
if (PUBLISH.test(line)) add('produces', repo.id, n.id, { file: nx.value.rel, line: i + 1, name: n.id });
|
|
990
|
+
else if (CONSUME.test(line)) add('consumes', n.id, repo.id, { file: nx.value.rel, line: i + 1, name: n.id });
|
|
991
|
+
} else if (n.kind === 'database' && CONNECT.test(line) && lits.some((l) => namesOf(n).some((nm) => tokenIn(l, nm)))) add('connects-to', repo.id, n.id, { file: nx.value.rel, line: i + 1, name: n.id });
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
if (files >= L.maxFilesPerRepo) truncated = true;
|
|
996
|
+
scanned[repo.id] = { files, truncated };
|
|
997
|
+
}
|
|
998
|
+
return {
|
|
999
|
+
edges: [...found.values()].sort((a, b) => (a.type + a.from + a.to < b.type + b.from + b.to ? -1 : 1)),
|
|
1000
|
+
scanned,
|
|
1001
|
+
limitations: [
|
|
1002
|
+
'Only string and URL literals are seen: a URL built at run time, read from the environment or routed by a mesh is not found.',
|
|
1003
|
+
'Names match as whole tokens; an alias must be declared in the manifest for a node whose code name differs from its id.',
|
|
1004
|
+
'The direction of a database use is not known, so those edges are `connects-to`.',
|
|
1005
|
+
'A discovered edge is a lead with file and line evidence, not a confirmed call.',
|
|
1006
|
+
],
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/** Discovered edges whose (type, from, to) the manifest already declares, and those it does not. */
|
|
1011
|
+
function splitDeclared(discovered, manifest) {
|
|
1012
|
+
const declared = new Set();
|
|
1013
|
+
for (const r of manifest.repositories) {
|
|
1014
|
+
for (const t of r.calls) declared.add(`calls\u0000${r.id}\u0000${t}`);
|
|
1015
|
+
for (const t of r.produces) declared.add(`produces\u0000${r.id}\u0000${t}`);
|
|
1016
|
+
for (const t of r.consumes) declared.add(`consumes\u0000${t}\u0000${r.id}`);
|
|
1017
|
+
for (const t of r.providers) declared.add(`uses-provider\u0000${r.id}\u0000${t}`);
|
|
1018
|
+
for (const t of [...r.reads, ...r.writes]) declared.add(`connects-to\u0000${r.id}\u0000${t}`);
|
|
1019
|
+
}
|
|
1020
|
+
const undeclared = []; const confirmed = [];
|
|
1021
|
+
for (const e of discovered.edges) (declared.has(`${e.type}\u0000${e.from}\u0000${e.to}`) ? confirmed : undeclared).push(e);
|
|
1022
|
+
return { undeclared, confirmed };
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
/***/ }),
|
|
1027
|
+
|
|
1028
|
+
/***/ 81630:
|
|
1029
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
1030
|
+
|
|
1031
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
1032
|
+
/* harmony export */ Nn: () => (/* binding */ attackPaths),
|
|
1033
|
+
/* harmony export */ Th: () => (/* binding */ finalizeGraph),
|
|
1034
|
+
/* harmony export */ Ws: () => (/* binding */ buildEnterpriseGraph),
|
|
1035
|
+
/* harmony export */ _o: () => (/* binding */ componentBlastRadius),
|
|
1036
|
+
/* harmony export */ aw: () => (/* binding */ INTERNET),
|
|
1037
|
+
/* harmony export */ cl: () => (/* binding */ validateEnterpriseGraph),
|
|
1038
|
+
/* harmony export */ lB: () => (/* binding */ renderBlastRadius),
|
|
1039
|
+
/* harmony export */ r8: () => (/* binding */ vulnerableSet)
|
|
1040
|
+
/* harmony export */ });
|
|
1041
|
+
/* unused harmony export MAX_DEPTH */
|
|
1042
|
+
/* harmony import */ var node_crypto__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(77598);
|
|
1043
|
+
// The enterprise graph (PRD 4, requirements 6 to 8): the repositories, APIs, queues, databases, external providers and trust zones
|
|
1044
|
+
// of an organization, with every edge carrying the evidence it came from.
|
|
1045
|
+
//
|
|
1046
|
+
// Two kinds of edge, never confused:
|
|
1047
|
+
// * `depends-on` edges come from each repository's own package manifest (the declared name and the spec it pins). A flaw in the
|
|
1048
|
+
// dependency can affect the dependent. They are NOT movement an attacker makes.
|
|
1049
|
+
// * REACH edges are what an attacker can move along: a call from one service to another, a producer to a queue, a queue to its
|
|
1050
|
+
// consumer, a service to a database or an external provider, and the Internet to anything declared Internet-facing. Those come
|
|
1051
|
+
// from the fleet manifest, which is operator-declared. They are labelled `source: fleet-manifest` and are exactly as good as the
|
|
1052
|
+
// operator's declaration; nothing here pretends to have discovered them. (Code-derived call and queue edges are what the Data
|
|
1053
|
+
// Flow Explorer's per-repository graph provides; joining that in is not built.)
|
|
1054
|
+
//
|
|
1055
|
+
// A graph edge without evidence is a bug, and `validateEnterpriseGraph` says so: "every graph edge traceable to source evidence".
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
const ENTERPRISE_GRAPH_SCHEMA = 'agentic-security/enterprise-graph';
|
|
1059
|
+
const INTERNET = 'internet';
|
|
1060
|
+
const MAX_PATHS = 200;
|
|
1061
|
+
const MAX_DEPTH = 8;
|
|
1062
|
+
const AUTH_CWES = new Set(['CWE-287', 'CWE-306', 'CWE-285', 'CWE-862', 'CWE-863', 'CWE-384', 'CWE-613', 'CWE-639', 'CWE-290', 'CWE-294', 'CWE-308', 'CWE-1390']);
|
|
1063
|
+
|
|
1064
|
+
const edgeId = (type, from, to) => `edge:${type}:${from}:${to}`;
|
|
1065
|
+
const hash = (v) => (0,node_crypto__WEBPACK_IMPORTED_MODULE_0__.createHash)('sha256').update(JSON.stringify(v)).digest('hex');
|
|
1066
|
+
|
|
1067
|
+
function buildEnterpriseGraph({ manifest, dependencyGraph, discovered = null }) {
|
|
1068
|
+
const nodes = new Map();
|
|
1069
|
+
const edges = new Map();
|
|
1070
|
+
const addNode = (n) => nodes.set(n.id, n);
|
|
1071
|
+
const addEdge = (type, from, to, evidence, reach) => {
|
|
1072
|
+
const id = edgeId(type, from, to);
|
|
1073
|
+
const e = edges.get(id) ?? { id, type, from, to, reach, evidence: [] };
|
|
1074
|
+
e.evidence.push(evidence);
|
|
1075
|
+
edges.set(id, e);
|
|
1076
|
+
};
|
|
1077
|
+
addNode({ id: INTERNET, type: 'internet', kind: 'internet', internetFacing: false, environment: null, dataClasses: [], trustZone: 'internet', required: false });
|
|
1078
|
+
for (const r of manifest.repositories) addNode({ id: r.id, type: 'repository', kind: r.kind, internetFacing: r.internetFacing, environment: r.environment, dataClasses: r.dataClasses, trustZone: r.trustZone, required: r.required });
|
|
1079
|
+
for (const r of manifest.resources ?? []) addNode({ id: r.id, type: r.kind, kind: r.kind, internetFacing: r.internetFacing, environment: r.environment, dataClasses: r.dataClasses, trustZone: r.trustZone, required: false });
|
|
1080
|
+
|
|
1081
|
+
const ev = (repo, field, value) => ({ source: 'fleet-manifest', repo, field, value });
|
|
1082
|
+
for (const n of [...nodes.values()]) if (n.internetFacing) addEdge('exposes', INTERNET, n.id, ev(n.id, 'internetFacing', true), true);
|
|
1083
|
+
for (const r of manifest.repositories) {
|
|
1084
|
+
for (const t of r.calls) addEdge('calls', r.id, t, ev(r.id, 'calls', t), true);
|
|
1085
|
+
for (const t of r.produces) addEdge('produces', r.id, t, ev(r.id, 'produces', t), true);
|
|
1086
|
+
for (const t of r.consumes) addEdge('consumes', t, r.id, ev(r.id, 'consumes', t), true);
|
|
1087
|
+
for (const t of r.reads) addEdge('reads', r.id, t, ev(r.id, 'reads', t), true);
|
|
1088
|
+
for (const t of r.writes) addEdge('writes', r.id, t, ev(r.id, 'writes', t), true);
|
|
1089
|
+
for (const t of r.providers) addEdge('uses-provider', r.id, t, ev(r.id, 'providers', t), true);
|
|
1090
|
+
}
|
|
1091
|
+
for (const d of dependencyGraph?.edges ?? []) addEdge('depends-on', d.from, d.to, d.evidence?.[0] ?? { source: 'package-manifest', repo: d.from, file: null, declared: d.via, spec: null }, false);
|
|
1092
|
+
// Edges found in the code. They share an id with a declared edge of the same type and endpoints, so the evidence list shows both; an
|
|
1093
|
+
// edge with ONLY discovered evidence is marked `discovered: true` so a query can say it rests on a code literal, not on a declaration.
|
|
1094
|
+
for (const d of discovered?.edges ?? []) for (const evidence of d.evidence) addEdge(d.type, d.from, d.to, evidence, true);
|
|
1095
|
+
for (const e of edges.values()) e.discovered = e.evidence.every((x) => x.source === 'discovered-code-literal');
|
|
1096
|
+
return { schema: ENTERPRISE_GRAPH_SCHEMA, version: 1, organization: manifest.organization, nodes: [...nodes.values()], edges: [...edges.values()], digest: null };
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function finalizeGraph(graph) { return { ...graph, digest: hash({ n: graph.nodes, e: graph.edges }) }; }
|
|
1100
|
+
|
|
1101
|
+
/** Every edge has evidence, every endpoint exists. Returns a list of problems. */
|
|
1102
|
+
function validateEnterpriseGraph(g) {
|
|
1103
|
+
const errs = [];
|
|
1104
|
+
const ids = new Set((g?.nodes ?? []).map((n) => n.id));
|
|
1105
|
+
for (const e of g?.edges ?? []) {
|
|
1106
|
+
if (!Array.isArray(e.evidence) || !e.evidence.length || !e.evidence.every((x) => x && typeof x.source === 'string')) errs.push(`edge ${e.id} has no source evidence`);
|
|
1107
|
+
if (!ids.has(e.from) || !ids.has(e.to)) errs.push(`edge ${e.id} points at a node that is not in the graph`);
|
|
1108
|
+
}
|
|
1109
|
+
return errs;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
const nodeOf = (g, id) => g.nodes.find((n) => n.id === id);
|
|
1113
|
+
|
|
1114
|
+
/** Repositories that match a "vulnerable code" selector, and everything that depends on them. */
|
|
1115
|
+
function vulnerableSet({ graph, findingsByRepo = new Map(), selector }) {
|
|
1116
|
+
const direct = new Set(); const why = new Map();
|
|
1117
|
+
if (selector.repo) { direct.add(selector.repo); why.set(selector.repo, 'named'); }
|
|
1118
|
+
for (const [repo, findings] of findingsByRepo) {
|
|
1119
|
+
const hit = (findings ?? []).find((f) => (selector.family === 'auth' ? (AUTH_CWES.has(String(f.cwe)) || /auth/i.test(String(f.family ?? ''))) : selector.family ? new RegExp(selector.family, 'i').test(String(f.family ?? f.vuln ?? '')) : false) || (selector.cwe && selector.cwe.includes(String(f.cwe))));
|
|
1120
|
+
if (hit) { direct.add(repo); why.set(repo, `finding ${hit.id ?? hit.stableId ?? ''} ${hit.cwe ?? ''}`.trim()); }
|
|
1121
|
+
}
|
|
1122
|
+
const incoming = new Map();
|
|
1123
|
+
for (const e of graph.edges) if (e.type === 'depends-on') { if (!incoming.has(e.to)) incoming.set(e.to, []); incoming.get(e.to).push(e.from); }
|
|
1124
|
+
const all = new Set(direct); const stack = [...direct];
|
|
1125
|
+
while (stack.length) { const c = stack.pop(); for (const f of incoming.get(c) ?? []) if (!all.has(f)) { all.add(f); why.set(f, `depends on ${c}`); stack.push(f); } }
|
|
1126
|
+
return { direct, all, why };
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* Attack paths: simple paths from the Internet to any node holding `toDataClass`, along reach edges only. A path is "involving"
|
|
1131
|
+
* when it passes through a repository in `involving`. Bounded in depth and count; `truncated` says when a bound was hit.
|
|
1132
|
+
*/
|
|
1133
|
+
function attackPaths({ graph, toDataClass, from = INTERNET, involving = null, maxDepth = MAX_DEPTH, maxPaths = MAX_PATHS }) {
|
|
1134
|
+
const out = new Map();
|
|
1135
|
+
for (const e of graph.edges) if (e.reach) { if (!out.has(e.from)) out.set(e.from, []); out.get(e.from).push(e); }
|
|
1136
|
+
const results = []; let truncated = false;
|
|
1137
|
+
const targets = new Set(graph.nodes.filter((n) => n.dataClasses.includes(toDataClass)).map((n) => n.id));
|
|
1138
|
+
const walk = (id, nodePath, edgePath) => {
|
|
1139
|
+
if (results.length >= maxPaths) { truncated = true; return; }
|
|
1140
|
+
if (targets.has(id) && nodePath.length > 1) {
|
|
1141
|
+
const inv = involving ? nodePath.filter((n) => involving.all.has(n)) : [];
|
|
1142
|
+
if (!involving || inv.length) results.push({ nodes: [...nodePath], edges: edgePath.map((e) => e.id), involving: inv, discoveredEdges: edgePath.filter((e) => e.discovered).map((e) => e.id), evidence: edgePath.flatMap((e) => e.evidence.map((x) => ({ edge: e.id, ...x }))) });
|
|
1143
|
+
}
|
|
1144
|
+
if (nodePath.length > maxDepth) { truncated = true; return; }
|
|
1145
|
+
for (const e of out.get(id) ?? []) if (!nodePath.includes(e.to)) walk(e.to, [...nodePath, e.to], [...edgePath, e]);
|
|
1146
|
+
};
|
|
1147
|
+
if (nodeOf(graph, from)) walk(from, [from], []);
|
|
1148
|
+
return { dataClass: toDataClass, targets: [...targets], paths: results, truncated, bounds: { maxDepth, maxPaths } };
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/** The shared-component report: who depends on `component` (transitively), and how many of them are exposed, in production, or hold regulated data. */
|
|
1152
|
+
function componentBlastRadius({ graph, component, version = null }) {
|
|
1153
|
+
if (!nodeOf(graph, component)) return null;
|
|
1154
|
+
const incoming = new Map();
|
|
1155
|
+
for (const e of graph.edges) if (e.type === 'depends-on') { if (!incoming.has(e.to)) incoming.set(e.to, []); incoming.get(e.to).push(e); }
|
|
1156
|
+
const found = new Map(); const queue = [{ id: component, path: [component], vm: 'not-asked' }];
|
|
1157
|
+
while (queue.length) {
|
|
1158
|
+
const { id, path, vm: parentVm } = queue.shift();
|
|
1159
|
+
for (const e of incoming.get(id) ?? []) {
|
|
1160
|
+
if (found.has(e.from)) continue;
|
|
1161
|
+
const spec = e.evidence.map((x) => x.spec).find((x) => x != null) ?? null;
|
|
1162
|
+
// only the dependents that pin the component itself can be judged against a version; one reached THROUGH another repository
|
|
1163
|
+
// inherits that repository's status, and is not reached at all when the repository in between was ruled out
|
|
1164
|
+
const direct = id === component;
|
|
1165
|
+
const vm = direct ? (version == null ? 'not-asked' : spec == null ? 'unknown' : String(spec).includes(version) ? 'matched' : 'mismatch') : parentVm;
|
|
1166
|
+
found.set(e.from, { id: e.from, depth: path.length, path: [...path, e.from], spec, versionMatch: vm, inherited: !direct, evidence: e.evidence });
|
|
1167
|
+
if (vm !== 'mismatch') queue.push({ id: e.from, path: [...path, e.from], vm });
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
const all = [...found.values()].sort((a, b) => a.depth - b.depth || a.id.localeCompare(b.id));
|
|
1171
|
+
const counted = all.filter((d) => d.versionMatch !== 'mismatch');
|
|
1172
|
+
const attrs = (d) => nodeOf(graph, d.id);
|
|
1173
|
+
const classes = {};
|
|
1174
|
+
for (const d of counted) for (const c of attrs(d).dataClasses) classes[c] = (classes[c] ?? 0) + 1;
|
|
1175
|
+
return {
|
|
1176
|
+
component, version, dependents: all,
|
|
1177
|
+
counts: { repositories: counted.length, internetFacing: counted.filter((d) => attrs(d).internetFacing).length, production: counted.filter((d) => attrs(d).environment === 'production').length, byDataClass: classes },
|
|
1178
|
+
excludedByVersion: all.filter((d) => d.versionMatch === 'mismatch').map((d) => d.id),
|
|
1179
|
+
unknownVersion: counted.filter((d) => d.versionMatch === 'unknown').map((d) => d.id),
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
function renderBlastRadius(r) {
|
|
1184
|
+
const n = (k, one, many) => `${k} ${k === 1 ? one : many}`;
|
|
1185
|
+
const L = [`${r.component}${r.version ? `@${r.version}` : ''}`, '', 'Used by:', n(r.counts.repositories, 'repository', 'repositories'), n(r.counts.internetFacing, 'Internet-facing service', 'Internet-facing services'), n(r.counts.production, 'production app', 'production apps')];
|
|
1186
|
+
for (const [c, k] of Object.entries(r.counts.byDataClass).sort()) L.push(n(k, `${c.toUpperCase()}-scoped service`, `${c.toUpperCase()}-scoped services`));
|
|
1187
|
+
if (r.excludedByVersion.length) L.push('', `Excluded, their pinned version does not match: ${r.excludedByVersion.join(', ')}`);
|
|
1188
|
+
if (r.unknownVersion.length) L.push('', `Counted, but their pinned version could not be read: ${r.unknownVersion.join(', ')}`);
|
|
1189
|
+
return L.join('\n');
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
/***/ }),
|
|
1194
|
+
|
|
1195
|
+
/***/ 94355:
|
|
1196
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
1197
|
+
|
|
1198
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
1199
|
+
/* harmony export */ X2: () => (/* binding */ blastRadius),
|
|
1200
|
+
/* harmony export */ u_: () => (/* binding */ buildFleetDependencyGraph)
|
|
1201
|
+
/* harmony export */ });
|
|
1202
|
+
/* unused harmony export readRepoManifest */
|
|
1203
|
+
/* harmony import */ var node_fs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(73024);
|
|
1204
|
+
/* harmony import */ var node_path__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(76760);
|
|
1205
|
+
// The cross-repository dependency graph for the fleet feature (PRD 4).
|
|
1206
|
+
//
|
|
1207
|
+
// Reads each repo's own manifest to learn (a) the package name it publishes under, if any, and (b) the package
|
|
1208
|
+
// names it depends on. An edge A -> B means "A declares a dependency that resolves to B's own published name" —
|
|
1209
|
+
// i.e. B is upstream of A, and a defect in B can affect A. This is a real, lightweight cross-repo dependency graph
|
|
1210
|
+
// built purely from manifests already on disk; it does not need deployment config (Kubernetes/compose/Terraform)
|
|
1211
|
+
// the way the existing deployment-boundary graph does, so a fleet of plain application repos works out of the box.
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
const MANIFESTS = Object.freeze([
|
|
1216
|
+
{ file: 'package.json', ecosystem: 'npm' },
|
|
1217
|
+
{ file: 'go.mod', ecosystem: 'go' },
|
|
1218
|
+
{ file: 'Cargo.toml', ecosystem: 'cargo' },
|
|
1219
|
+
{ file: 'requirements.txt', ecosystem: 'pypi' },
|
|
1220
|
+
{ file: 'pyproject.toml', ecosystem: 'pypi' },
|
|
1221
|
+
]);
|
|
1222
|
+
|
|
1223
|
+
function readBounded(p, maxBytes = 1024 * 1024) {
|
|
1224
|
+
try {
|
|
1225
|
+
const st = node_fs__WEBPACK_IMPORTED_MODULE_0__.statSync(p);
|
|
1226
|
+
if (!st.isFile() || st.size > maxBytes) return null;
|
|
1227
|
+
return node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(p, 'utf8');
|
|
1228
|
+
} catch { return null; }
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
function parsePackageJson(text) {
|
|
1232
|
+
let j;
|
|
1233
|
+
try { j = JSON.parse(text); } catch { return { name: null, deps: new Set() }; }
|
|
1234
|
+
const specs = new Map();
|
|
1235
|
+
for (const block of [j.peerDependencies, j.devDependencies, j.dependencies]) for (const [k, v] of Object.entries(block || {})) if (typeof v === 'string') specs.set(k, v.slice(0, 200));
|
|
1236
|
+
const deps = new Set([...Object.keys(j.dependencies || {}), ...Object.keys(j.devDependencies || {}), ...Object.keys(j.peerDependencies || {})]);
|
|
1237
|
+
return { name: typeof j.name === 'string' ? j.name : null, deps, specs, version: typeof j.version === 'string' ? j.version : null };
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
function parseGoMod(text) {
|
|
1241
|
+
const m = /^module\s+(\S+)/m.exec(text);
|
|
1242
|
+
const deps = new Set();
|
|
1243
|
+
const block = /require\s*\(([\s\S]*?)\)/.exec(text);
|
|
1244
|
+
const lines = block ? block[1].split('\n') : text.split('\n').filter((l) => /^require\s/.test(l.trim()));
|
|
1245
|
+
for (const raw of lines) {
|
|
1246
|
+
const l = raw.replace(/^require\s+/, '').trim();
|
|
1247
|
+
const dm = /^(\S+)\s+v?\S+/.exec(l);
|
|
1248
|
+
if (dm) deps.add(dm[1]);
|
|
1249
|
+
}
|
|
1250
|
+
return { name: m ? m[1] : null, deps };
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
function parseCargoToml(text) {
|
|
1254
|
+
const nm = /\[package\][\s\S]*?\bname\s*=\s*"([^"]+)"/.exec(text);
|
|
1255
|
+
const deps = new Set();
|
|
1256
|
+
const section = /\[dependencies\]([\s\S]*?)(\n\[|$)/.exec(text);
|
|
1257
|
+
if (section) {
|
|
1258
|
+
for (const line of section[1].split('\n')) {
|
|
1259
|
+
const dm = /^\s*([A-Za-z0-9_-]+)\s*=/.exec(line);
|
|
1260
|
+
if (dm) deps.add(dm[1]);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
return { name: nm ? nm[1] : null, deps };
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
function parseRequirementsTxt(text) {
|
|
1267
|
+
const deps = new Set();
|
|
1268
|
+
for (const raw of text.split('\n')) {
|
|
1269
|
+
const l = raw.trim();
|
|
1270
|
+
if (!l || l.startsWith('#') || l.startsWith('-')) continue;
|
|
1271
|
+
const dm = /^([A-Za-z0-9._-]+)/.exec(l);
|
|
1272
|
+
if (dm) deps.add(dm[1].toLowerCase());
|
|
1273
|
+
}
|
|
1274
|
+
return { name: null, deps };
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
function parsePyprojectToml(text) {
|
|
1278
|
+
const nm = /\[project\][\s\S]*?\bname\s*=\s*"([^"]+)"/.exec(text);
|
|
1279
|
+
const deps = new Set();
|
|
1280
|
+
const section = /dependencies\s*=\s*\[([\s\S]*?)\]/.exec(text);
|
|
1281
|
+
if (section) {
|
|
1282
|
+
for (const m of section[1].matchAll(/"([A-Za-z0-9._-]+)/g)) deps.add(m[1].toLowerCase());
|
|
1283
|
+
}
|
|
1284
|
+
return { name: nm ? nm[1] : null, deps };
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
const PARSERS = { 'package.json': parsePackageJson, 'go.mod': parseGoMod, 'Cargo.toml': parseCargoToml, 'requirements.txt': parseRequirementsTxt, 'pyproject.toml': parsePyprojectToml };
|
|
1288
|
+
|
|
1289
|
+
/** One repo's declared identity and dependency names, merged across every manifest file it has. */
|
|
1290
|
+
function readRepoManifest(repoPath) {
|
|
1291
|
+
let name = null;
|
|
1292
|
+
const deps = new Set();
|
|
1293
|
+
const ecosystems = new Set();
|
|
1294
|
+
const specs = new Map();
|
|
1295
|
+
const files = new Map();
|
|
1296
|
+
for (const { file, ecosystem } of MANIFESTS) {
|
|
1297
|
+
const text = readBounded(node_path__WEBPACK_IMPORTED_MODULE_1__.join(repoPath, file));
|
|
1298
|
+
if (text == null) continue;
|
|
1299
|
+
const parsed = PARSERS[file](text);
|
|
1300
|
+
if (parsed.name && !name) name = parsed.name;
|
|
1301
|
+
for (const d of parsed.deps) { deps.add(d); if (!files.has(d)) files.set(d, file); }
|
|
1302
|
+
for (const [d, spec] of parsed.specs ?? []) specs.set(d, spec);
|
|
1303
|
+
ecosystems.add(ecosystem);
|
|
1304
|
+
}
|
|
1305
|
+
return { name, deps, ecosystems: [...ecosystems], specs, files };
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/**
|
|
1309
|
+
* Build the fleet dependency graph. `repos`: [{id, path}]. An edge {from, to} means `from` depends on `to` (so a
|
|
1310
|
+
* defect in `to` can affect `from`). Matching is by declared package NAME only — two repos that happen to share a
|
|
1311
|
+
* name under different ecosystems are not disambiguated, a disclosed limit, not a guess.
|
|
1312
|
+
*/
|
|
1313
|
+
function buildFleetDependencyGraph(repos) {
|
|
1314
|
+
const manifests = new Map();
|
|
1315
|
+
for (const r of repos) manifests.set(r.id, { ...r, manifest: readRepoManifest(r.path) });
|
|
1316
|
+
|
|
1317
|
+
const byName = new Map();
|
|
1318
|
+
for (const [id, r] of manifests) if (r.manifest.name) {
|
|
1319
|
+
if (!byName.has(r.manifest.name)) byName.set(r.manifest.name, []);
|
|
1320
|
+
byName.get(r.manifest.name).push(id);
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
const edges = [];
|
|
1324
|
+
for (const [id, r] of manifests) {
|
|
1325
|
+
for (const dep of r.manifest.deps) {
|
|
1326
|
+
const targets = byName.get(dep);
|
|
1327
|
+
if (!targets) continue;
|
|
1328
|
+
for (const to of targets) if (to !== id) edges.push({ from: id, to, via: dep, evidence: [{ source: 'package-manifest', repo: id, file: r.manifest.files.get(dep) ?? null, declared: dep, spec: r.manifest.specs.get(dep) ?? null }] });
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
const nodes = [...manifests.values()].map((r) => ({ id: r.id, path: r.path, declaredName: r.manifest.name, ecosystems: r.manifest.ecosystems }));
|
|
1332
|
+
return { nodes, edges };
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
/** Transitive downstream closure: which fleet repos depend on `repoId`, directly or transitively, with the shortest path to each. */
|
|
1336
|
+
function blastRadius(graph, repoId) {
|
|
1337
|
+
const incoming = new Map();
|
|
1338
|
+
for (const e of graph.edges) { if (!incoming.has(e.to)) incoming.set(e.to, []); incoming.get(e.to).push(e.from); }
|
|
1339
|
+
const best = new Map();
|
|
1340
|
+
const queue = [{ id: repoId, path: [repoId] }];
|
|
1341
|
+
while (queue.length) {
|
|
1342
|
+
const { id, path: p } = queue.shift();
|
|
1343
|
+
for (const from of incoming.get(id) || []) {
|
|
1344
|
+
if (best.has(from)) continue;
|
|
1345
|
+
const newPath = [...p, from];
|
|
1346
|
+
best.set(from, { depth: newPath.length - 1, path: newPath });
|
|
1347
|
+
queue.push({ id: from, path: newPath });
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
return [...best.entries()].map(([id, v]) => ({ id, ...v })).sort((a, b) => a.depth - b.depth || (a.id < b.id ? -1 : 1));
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
/***/ }),
|
|
1355
|
+
|
|
1356
|
+
/***/ 62790:
|
|
1357
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
1358
|
+
|
|
1359
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
1360
|
+
/* harmony export */ Cb: () => (/* binding */ loadFleetManifest),
|
|
1361
|
+
/* harmony export */ yr: () => (/* binding */ starterManifestYaml)
|
|
1362
|
+
/* harmony export */ });
|
|
1363
|
+
/* unused harmony exports ENVIRONMENTS, parseDuration, validateFleetManifest */
|
|
1364
|
+
/* harmony import */ var node_fs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(73024);
|
|
1365
|
+
/* harmony import */ var node_path__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(76760);
|
|
1366
|
+
/* harmony import */ var _util_yaml_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(82340);
|
|
1367
|
+
// The fleet manifest (PRD 4 "Fleet Manifest"). `fleet.yaml`:
|
|
1368
|
+
//
|
|
1369
|
+
// organization: example
|
|
1370
|
+
// repositories:
|
|
1371
|
+
// - id: frontend
|
|
1372
|
+
// path: ../frontend
|
|
1373
|
+
// workers: 4
|
|
1374
|
+
// perRepoTimeout: 20m
|
|
1375
|
+
//
|
|
1376
|
+
// Parsing reuses util/yaml.js (the one YAML entry point in this codebase). Validation is closed-world: an unknown
|
|
1377
|
+
// top-level key, a duplicate repository id, or a path that does not resolve to a real directory all refuse the
|
|
1378
|
+
// whole manifest rather than silently skipping one repo.
|
|
1379
|
+
|
|
1380
|
+
|
|
1381
|
+
|
|
1382
|
+
|
|
1383
|
+
const FLEET_MANIFEST_SCHEMA_KEYS = Object.freeze(['organization', 'repositories', 'workers', 'perRepoTimeout', 'retries', 'resources']);
|
|
1384
|
+
const REPO_KEYS = Object.freeze(['id', 'path', 'required', 'kind', 'internetFacing', 'environment', 'dataClasses', 'trustZone', 'calls', 'produces', 'consumes', 'reads', 'writes', 'providers', 'aliases']);
|
|
1385
|
+
const RESOURCE_KEYS = Object.freeze(['id', 'kind', 'dataClasses', 'trustZone', 'internetFacing', 'environment', 'aliases']);
|
|
1386
|
+
const REPO_KINDS = Object.freeze(['service', 'library', 'frontend', 'worker', 'gateway']);
|
|
1387
|
+
const RESOURCE_KINDS = Object.freeze(['queue', 'database', 'external-provider', 'gateway']);
|
|
1388
|
+
const ENVIRONMENTS = Object.freeze(['production', 'staging', 'development']);
|
|
1389
|
+
const NAME_RE = /^[a-z][a-z0-9-]{0,63}$/;
|
|
1390
|
+
const DURATION_RE = /^(\d+)(s|m|h)$/;
|
|
1391
|
+
|
|
1392
|
+
function parseDuration(s, fallbackMs) {
|
|
1393
|
+
if (typeof s === 'number' && s > 0) return s * 1000;
|
|
1394
|
+
const m = DURATION_RE.exec(String(s ?? ''));
|
|
1395
|
+
if (!m) return fallbackMs;
|
|
1396
|
+
const n = Number(m[1]);
|
|
1397
|
+
return m[2] === 's' ? n * 1000 : m[2] === 'm' ? n * 60000 : n * 3600000;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
/** Validate a parsed manifest object against the manifest's own base directory (for path resolution). */
|
|
1401
|
+
function validateFleetManifest(doc, { baseDir = '.' } = {}) {
|
|
1402
|
+
const errors = [];
|
|
1403
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return { ok: false, errors: ['manifest must be a YAML mapping'] };
|
|
1404
|
+
for (const k of Object.keys(doc)) if (!FLEET_MANIFEST_SCHEMA_KEYS.includes(k)) errors.push(`unknown top-level key: ${k}`);
|
|
1405
|
+
if (typeof doc.organization !== 'string' || !doc.organization) errors.push('organization is required and must be a string');
|
|
1406
|
+
if (!Array.isArray(doc.repositories) || doc.repositories.length === 0) errors.push('repositories must be a non-empty array');
|
|
1407
|
+
const seen = new Set();
|
|
1408
|
+
const repos = [];
|
|
1409
|
+
const idList = (v, where) => { if (v === undefined) return []; if (!Array.isArray(v) || !v.every((x) => typeof x === 'string' && NAME_RE.test(x))) { errors.push(`${where} must be a list of ids`); return []; } return v; };
|
|
1410
|
+
const aliasList = (v, where) => { if (v === undefined) return []; if (!Array.isArray(v) || v.length > 16 || !v.every((x) => typeof x === 'string' && /^[a-z0-9][a-z0-9._-]{0,99}$/.test(x))) { errors.push(`${where} must be a list of up to 16 lower-case names or hostnames`); return []; } return v; };
|
|
1411
|
+
const classes = (v, where) => { if (v === undefined) return []; if (!Array.isArray(v) || !v.every((x) => typeof x === 'string' && /^[a-z][a-z0-9-]{0,30}$/.test(x))) { errors.push(`${where} must be a list of data class names such as pci or pii`); return []; } return v; };
|
|
1412
|
+
for (const [i, r] of (Array.isArray(doc.repositories) ? doc.repositories : []).entries()) {
|
|
1413
|
+
if (!r || typeof r !== 'object') { errors.push(`repositories[${i}] must be a mapping`); continue; }
|
|
1414
|
+
for (const k of Object.keys(r)) if (!REPO_KEYS.includes(k)) errors.push(`repositories[${i}]: unknown key ${k}`);
|
|
1415
|
+
if (typeof r.id !== 'string' || !NAME_RE.test(r.id)) { errors.push(`repositories[${i}].id must be a lowercase kebab-case string`); continue; }
|
|
1416
|
+
if (seen.has(r.id)) { errors.push(`duplicate repository id: ${r.id}`); continue; }
|
|
1417
|
+
seen.add(r.id);
|
|
1418
|
+
if (typeof r.path !== 'string' || !r.path) { errors.push(`repositories[${i}].path is required`); continue; }
|
|
1419
|
+
const abs = node_path__WEBPACK_IMPORTED_MODULE_1__.resolve(baseDir, r.path);
|
|
1420
|
+
let isDir = false;
|
|
1421
|
+
try { isDir = node_fs__WEBPACK_IMPORTED_MODULE_0__.statSync(abs).isDirectory(); } catch { /* reported below */ }
|
|
1422
|
+
if (!isDir) errors.push(`repositories[${i}].path does not resolve to a directory: ${r.path}`);
|
|
1423
|
+
if (r.kind !== undefined && !REPO_KINDS.includes(r.kind)) errors.push(`repositories[${i}].kind must be one of ${REPO_KINDS.join(', ')}`);
|
|
1424
|
+
if (r.environment !== undefined && !ENVIRONMENTS.includes(r.environment)) errors.push(`repositories[${i}].environment must be one of ${ENVIRONMENTS.join(', ')}`);
|
|
1425
|
+
for (const k of ['required', 'internetFacing']) if (r[k] !== undefined && typeof r[k] !== 'boolean') errors.push(`repositories[${i}].${k} must be true or false`);
|
|
1426
|
+
if (r.trustZone !== undefined && !(typeof r.trustZone === 'string' && NAME_RE.test(r.trustZone))) errors.push(`repositories[${i}].trustZone must be a name`);
|
|
1427
|
+
repos.push({
|
|
1428
|
+
id: r.id, path: abs, required: r.required !== false, kind: r.kind ?? null, internetFacing: r.internetFacing === true, environment: r.environment ?? null,
|
|
1429
|
+
dataClasses: classes(r.dataClasses, `repositories[${i}].dataClasses`), trustZone: r.trustZone ?? null,
|
|
1430
|
+
aliases: aliasList(r.aliases, `repositories[${i}].aliases`), calls: idList(r.calls, `repositories[${i}].calls`), produces: idList(r.produces, `repositories[${i}].produces`), consumes: idList(r.consumes, `repositories[${i}].consumes`),
|
|
1431
|
+
reads: idList(r.reads, `repositories[${i}].reads`), writes: idList(r.writes, `repositories[${i}].writes`), providers: idList(r.providers, `repositories[${i}].providers`),
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
const resources = [];
|
|
1435
|
+
const resIds = new Set();
|
|
1436
|
+
if (doc.resources !== undefined && !Array.isArray(doc.resources)) errors.push('resources must be a list');
|
|
1437
|
+
for (const [i, r] of (Array.isArray(doc.resources) ? doc.resources : []).entries()) {
|
|
1438
|
+
if (!r || typeof r !== 'object') { errors.push(`resources[${i}] must be a mapping`); continue; }
|
|
1439
|
+
for (const k of Object.keys(r)) if (!RESOURCE_KEYS.includes(k)) errors.push(`resources[${i}]: unknown key ${k}`);
|
|
1440
|
+
if (typeof r.id !== 'string' || !NAME_RE.test(r.id)) { errors.push(`resources[${i}].id must be a lowercase kebab-case string`); continue; }
|
|
1441
|
+
if (seen.has(r.id) || resIds.has(r.id)) { errors.push(`resource id ${r.id} collides with another repository or resource`); continue; }
|
|
1442
|
+
resIds.add(r.id);
|
|
1443
|
+
if (!RESOURCE_KINDS.includes(r.kind)) errors.push(`resources[${i}].kind must be one of ${RESOURCE_KINDS.join(', ')}`);
|
|
1444
|
+
if (r.environment !== undefined && !ENVIRONMENTS.includes(r.environment)) errors.push(`resources[${i}].environment must be one of ${ENVIRONMENTS.join(', ')}`);
|
|
1445
|
+
if (r.internetFacing !== undefined && typeof r.internetFacing !== 'boolean') errors.push(`resources[${i}].internetFacing must be true or false`);
|
|
1446
|
+
resources.push({ id: r.id, kind: r.kind, aliases: aliasList(r.aliases, `resources[${i}].aliases`), dataClasses: classes(r.dataClasses, `resources[${i}].dataClasses`), trustZone: r.trustZone ?? null, internetFacing: r.internetFacing === true, environment: r.environment ?? null });
|
|
1447
|
+
}
|
|
1448
|
+
const known = new Set([...seen, ...resIds]);
|
|
1449
|
+
for (const r of repos) {
|
|
1450
|
+
for (const [field, kinds] of [['calls', ['repo', 'gateway']], ['produces', ['queue']], ['consumes', ['queue']], ['reads', ['database']], ['writes', ['database']], ['providers', ['external-provider']]]) {
|
|
1451
|
+
for (const target of r[field]) {
|
|
1452
|
+
if (!known.has(target)) { errors.push(`repository ${r.id}: ${field} names ${target}, which is neither a repository nor a resource`); continue; }
|
|
1453
|
+
const res = resources.find((x) => x.id === target);
|
|
1454
|
+
const ok = res ? kinds.includes(res.kind) : kinds.includes('repo');
|
|
1455
|
+
if (!ok) errors.push(`repository ${r.id}: ${field} cannot point at ${target}${res ? ` (a ${res.kind})` : ' (a repository)'}`);
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
if (doc.workers != null && !(Number.isInteger(doc.workers) && doc.workers >= 1 && doc.workers <= 32)) errors.push('workers must be an integer in 1..32');
|
|
1460
|
+
if (doc.retries != null && !(Number.isInteger(doc.retries) && doc.retries >= 0 && doc.retries <= 5)) errors.push('retries must be an integer in 0..5');
|
|
1461
|
+
if (doc.perRepoTimeout != null && parseDuration(doc.perRepoTimeout, null) == null) errors.push(`perRepoTimeout is not a valid duration: ${doc.perRepoTimeout}`);
|
|
1462
|
+
if (errors.length) return { ok: false, errors };
|
|
1463
|
+
return {
|
|
1464
|
+
ok: true, errors: [],
|
|
1465
|
+
manifest: {
|
|
1466
|
+
organization: doc.organization, repositories: repos, resources,
|
|
1467
|
+
workers: doc.workers ?? 4, retries: doc.retries ?? 1, perRepoTimeoutMs: parseDuration(doc.perRepoTimeout, 20 * 60000),
|
|
1468
|
+
},
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
function loadFleetManifest(filePath) {
|
|
1473
|
+
let text;
|
|
1474
|
+
try { text = node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(filePath, 'utf8'); } catch (e) { return { ok: false, errors: [`cannot read ${filePath}: ${e.message}`] }; }
|
|
1475
|
+
let doc;
|
|
1476
|
+
try { doc = (0,_util_yaml_js__WEBPACK_IMPORTED_MODULE_2__/* .load */ .Hh)(text); } catch (e) { return { ok: false, errors: [`invalid YAML: ${e.message}`] }; }
|
|
1477
|
+
return validateFleetManifest(doc, { baseDir: node_path__WEBPACK_IMPORTED_MODULE_1__.dirname(node_path__WEBPACK_IMPORTED_MODULE_1__.resolve(filePath)) });
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
function starterManifestYaml(organization = 'example') {
|
|
1481
|
+
return `organization: ${organization}
|
|
1482
|
+
repositories:
|
|
1483
|
+
- id: example-repo
|
|
1484
|
+
path: .
|
|
1485
|
+
workers: 4
|
|
1486
|
+
perRepoTimeout: 20m
|
|
1487
|
+
`;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
|
|
1491
|
+
/***/ }),
|
|
1492
|
+
|
|
1493
|
+
/***/ 89072:
|
|
1494
|
+
/***/ ((__webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
1495
|
+
|
|
1496
|
+
__webpack_require__.a(__webpack_module__, async (__webpack_handle_async_dependencies__, __webpack_async_result__) => { try {
|
|
1497
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
1498
|
+
/* harmony export */ Ag: () => (/* binding */ runJobs),
|
|
1499
|
+
/* harmony export */ IT: () => (/* binding */ childScan),
|
|
1500
|
+
/* harmony export */ kZ: () => (/* binding */ loadArtifacts),
|
|
1501
|
+
/* harmony export */ nY: () => (/* binding */ planRun),
|
|
1502
|
+
/* harmony export */ zD: () => (/* binding */ reclaimDeadWorkers)
|
|
1503
|
+
/* harmony export */ });
|
|
1504
|
+
/* unused harmony export DEFAULTS */
|
|
1505
|
+
/* harmony import */ var node_fs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(73024);
|
|
1506
|
+
/* harmony import */ var node_os__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(48161);
|
|
1507
|
+
/* harmony import */ var node_path__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(76760);
|
|
1508
|
+
/* harmony import */ var node_crypto__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(77598);
|
|
1509
|
+
/* harmony import */ var node_url__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(73136);
|
|
1510
|
+
/* harmony import */ var _sandbox_supervise_js__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(74676);
|
|
1511
|
+
/* harmony import */ var _state_dir_js__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(31174);
|
|
1512
|
+
/* harmony import */ var _scan_cache_js__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(75846);
|
|
1513
|
+
/* harmony import */ var _scan_child_js__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(3398);
|
|
1514
|
+
var __webpack_async_dependencies__ = __webpack_handle_async_dependencies__([_scan_child_js__WEBPACK_IMPORTED_MODULE_8__]);
|
|
1515
|
+
_scan_child_js__WEBPACK_IMPORTED_MODULE_8__ = (__webpack_async_dependencies__.then ? (await __webpack_async_dependencies__)() : __webpack_async_dependencies__)[0];
|
|
1516
|
+
// The fleet scan controller (PRD 4, requirements 2 to 5). It turns a manifest into jobs in the job store and runs them with bounded
|
|
1517
|
+
// workers, each scan in its own child process under a hard timeout, so one hung repository costs one worker for one timeout and
|
|
1518
|
+
// nothing else. Every state change is in the store, which is what makes `fleet resume` an ordinary continuation.
|
|
1519
|
+
//
|
|
1520
|
+
// plan fingerprint each repository, derive its cache key, enqueue one idempotent job per repository
|
|
1521
|
+
// run claim -> (cache hit | scan in a supervised child, heartbeating) -> complete | fail-with-retry
|
|
1522
|
+
// recover an expired lease, or a lease held by a dead process on this host, goes back in the queue
|
|
1523
|
+
//
|
|
1524
|
+
// A worker that loses its lease (it stopped heartbeating long enough for another worker to take the job) is fenced out: its late
|
|
1525
|
+
// result is refused by the store and its scan is aborted. Results are written per attempt (`<repo>.<epoch>.json`) and the job row
|
|
1526
|
+
// points at the winning one, so a retry can never produce two sets of findings for one repository.
|
|
1527
|
+
|
|
1528
|
+
|
|
1529
|
+
|
|
1530
|
+
|
|
1531
|
+
|
|
1532
|
+
|
|
1533
|
+
|
|
1534
|
+
|
|
1535
|
+
|
|
1536
|
+
|
|
1537
|
+
const SCAN_CHILD = (0,node_url__WEBPACK_IMPORTED_MODULE_4__.fileURLToPath)(new URL(/* asset import */ __webpack_require__(53948), __webpack_require__.b));
|
|
1538
|
+
const sha = (s) => (0,node_crypto__WEBPACK_IMPORTED_MODULE_3__.createHash)('sha256').update(s).digest('hex');
|
|
1539
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1540
|
+
const DEFAULTS = Object.freeze({ leaseMs: 30_000, heartbeatMs: 10_000, retryDelayMs: 2_000, pollMs: 100 });
|
|
1541
|
+
|
|
1542
|
+
/** The default scan: one supervised child process per repository. Throws `{code: 'TIMEOUT'|'ERROR', message}`. */
|
|
1543
|
+
async function childScan(repoPath, { timeoutMs, signal }) {
|
|
1544
|
+
const r = await (0,_sandbox_supervise_js__WEBPACK_IMPORTED_MODULE_5__/* .superviseSpawn */ .u)(process.execPath, [SCAN_CHILD, repoPath], { cwd: repoPath, env: { ...process.env, AGENTIC_SECURITY_NO_STATE: '1' }, timeoutMs, signal, maxOutputBytes: 96 * 1024 * 1024 });
|
|
1545
|
+
const fail = (code, message) => Object.assign(new Error(message), { code });
|
|
1546
|
+
if (r.timedOut) throw fail('TIMEOUT', `no result within ${Math.round(timeoutMs / 1000)}s`);
|
|
1547
|
+
if (r.cancelled) throw fail('CANCELLED', 'cancelled');
|
|
1548
|
+
if (r.spawnError) throw fail('ERROR', `could not start the scan: ${r.spawnError}`);
|
|
1549
|
+
if (r.outputCapped) throw fail('ERROR', 'the scan produced more output than allowed');
|
|
1550
|
+
if (r.exitCode !== 0) throw fail('ERROR', `scan exited ${r.exitCode}: ${String(r.stderr).trim().split('\n').pop()?.slice(0, 200) ?? ''}`);
|
|
1551
|
+
const a = r.stdout.indexOf(_scan_child_js__WEBPACK_IMPORTED_MODULE_8__/* .BEGIN */ .Q); const b = r.stdout.indexOf(_scan_child_js__WEBPACK_IMPORTED_MODULE_8__/* .END */ .K);
|
|
1552
|
+
if (a < 0 || b < a) throw fail('ERROR', 'the scan produced no result block');
|
|
1553
|
+
try { return JSON.parse(r.stdout.slice(a + _scan_child_js__WEBPACK_IMPORTED_MODULE_8__/* .BEGIN */ .Q.length, b)); } catch (e) { throw fail('ERROR', `unreadable scan result: ${e.message}`); }
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
const hostId = () => node_os__WEBPACK_IMPORTED_MODULE_1__.hostname();
|
|
1557
|
+
const pidAlive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } };
|
|
1558
|
+
|
|
1559
|
+
/** Free jobs held by workers on THIS host whose process no longer exists. Other hosts are handled by lease expiry alone. */
|
|
1560
|
+
function reclaimDeadWorkers(store, runId, { host = hostId(), alive = pidAlive, self = null } = {}) {
|
|
1561
|
+
let freed = 0;
|
|
1562
|
+
const seen = new Set();
|
|
1563
|
+
for (const j of store.listJobs(runId)) {
|
|
1564
|
+
if (j.status !== 'leased' || !j.worker || seen.has(j.worker) || j.worker === self) continue;
|
|
1565
|
+
const m = /^(.*):(\d+):\d+$/.exec(j.worker);
|
|
1566
|
+
if (!m || m[1] !== host) continue;
|
|
1567
|
+
seen.add(j.worker);
|
|
1568
|
+
if (!alive(Number(m[2]))) freed += store.reclaimWorker(runId, j.worker);
|
|
1569
|
+
}
|
|
1570
|
+
return freed;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
function planRun({ manifest, manifestHash, store, runId = null, config = {}, now = Date.now(), identity = (0,_scan_cache_js__WEBPACK_IMPORTED_MODULE_7__/* .scannerIdentity */ .fs)(), fingerprint = _scan_cache_js__WEBPACK_IMPORTED_MODULE_7__/* .repoFingerprint */ .NJ, retries = manifest.retries, meta = {} }) {
|
|
1574
|
+
const scanConfig = { provenance: false, ...config };
|
|
1575
|
+
const prints = manifest.repositories.map((r) => ({ r, fp: fingerprint(r.path) }));
|
|
1576
|
+
const configHash = sha(JSON.stringify(scanConfig));
|
|
1577
|
+
const id = runId ?? `frun-${sha(JSON.stringify({ manifestHash, configHash, identity, t: now, prints: prints.map((p) => [p.r.id, p.fp.content]) })).slice(0, 16)}`;
|
|
1578
|
+
store.begin({ runId: id, organization: manifest.organization, manifestHash, configHash, scannerIdentity: identity, createdAt: now, meta: { workers: manifest.workers, perRepoTimeoutMs: manifest.perRepoTimeoutMs, retries, ...meta } });
|
|
1579
|
+
const created = store.enqueue(id, prints.map(({ r, fp }) => ({ jobId: `${id}:${r.id}`, repoId: r.id, commit: fp.commit, cacheKey: (0,_scan_cache_js__WEBPACK_IMPORTED_MODULE_7__/* .cacheKey */ .k$)({ fingerprint: fp, config: scanConfig, identity }), timeoutMs: manifest.perRepoTimeoutMs, maxAttempts: retries + 1, required: r.required })), { now });
|
|
1580
|
+
return { runId: id, created, identity };
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
/**
|
|
1584
|
+
* Run (or continue) a planned run until no job is queued or leased by a live worker. Returns when the work is done, the signal
|
|
1585
|
+
* aborts, or `maxWaitMs` passes with only other workers' leases outstanding.
|
|
1586
|
+
*/
|
|
1587
|
+
async function runJobs({ manifest, store, runId, root, scan = childScan, workers = manifest.workers, leaseMs = DEFAULTS.leaseMs, heartbeatMs = DEFAULTS.heartbeatMs, retryDelayMs = DEFAULTS.retryDelayMs, pollMs = DEFAULTS.pollMs, maxWaitMs = null, signal, now = () => Date.now(), onEvent = () => {}, cacheDir = null, artifactDir = null, useCache = true }) {
|
|
1588
|
+
const repoById = new Map(manifest.repositories.map((r) => [r.id, r]));
|
|
1589
|
+
const cacheD = cacheDir ?? (0,_state_dir_js__WEBPACK_IMPORTED_MODULE_6__.statePath)(root, 'fleet', 'cache');
|
|
1590
|
+
const artD = artifactDir ?? (0,_state_dir_js__WEBPACK_IMPORTED_MODULE_6__.statePath)(root, 'fleet', 'artifacts', runId);
|
|
1591
|
+
node_fs__WEBPACK_IMPORTED_MODULE_0__.mkdirSync(artD, { recursive: true, mode: 0o700 });
|
|
1592
|
+
const deadline = maxWaitMs ?? (manifest.perRepoTimeoutMs * 3 + leaseMs);
|
|
1593
|
+
const writeArtifact = (job, payload) => {
|
|
1594
|
+
const text = JSON.stringify({ schema: 'agentic-security/fleet-scan-artifact', version: 1, runId, repoId: job.repoId, epoch: job.epoch, commit: job.commit, ...payload });
|
|
1595
|
+
const file = node_path__WEBPACK_IMPORTED_MODULE_2__.join(artD, `${job.repoId}.${job.epoch}.json`);
|
|
1596
|
+
node_fs__WEBPACK_IMPORTED_MODULE_0__.writeFileSync(file, text, { mode: 0o600 });
|
|
1597
|
+
return { file, sha256: sha(text) };
|
|
1598
|
+
};
|
|
1599
|
+
|
|
1600
|
+
async function worker(slot) {
|
|
1601
|
+
const id = `${hostId()}:${process.pid}:${slot}`;
|
|
1602
|
+
let idleSince = null;
|
|
1603
|
+
for (;;) {
|
|
1604
|
+
if (signal?.aborted) return;
|
|
1605
|
+
store.reclaimExpired(runId, { now: now() });
|
|
1606
|
+
const job = store.claim(runId, { worker: id, leaseMs, now: now() });
|
|
1607
|
+
if (!job) {
|
|
1608
|
+
const open = store.listJobs(runId).filter((j) => j.status === 'queued' || j.status === 'leased');
|
|
1609
|
+
if (!open.length) return;
|
|
1610
|
+
idleSince ??= now();
|
|
1611
|
+
if (now() - idleSince > deadline) return;
|
|
1612
|
+
await sleep(pollMs);
|
|
1613
|
+
continue;
|
|
1614
|
+
}
|
|
1615
|
+
idleSince = null;
|
|
1616
|
+
const repo = repoById.get(job.repoId);
|
|
1617
|
+
onEvent({ type: 'start', repo: job.repoId, attempt: job.attempts });
|
|
1618
|
+
const cached = useCache ? (0,_scan_cache_js__WEBPACK_IMPORTED_MODULE_7__/* .readCache */ .xe)(cacheD, job.cacheKey) : null;
|
|
1619
|
+
if (cached) {
|
|
1620
|
+
const a = writeArtifact(job, { entry: cached.entry, findings: cached.findings, findingsTruncated: !!cached.findingsTruncated, cached: true });
|
|
1621
|
+
const status = cached.entry?.scanHealth && cached.entry.scanHealth.status !== 'complete' ? 'partial' : 'succeeded';
|
|
1622
|
+
if (store.complete(job.jobId, job.epoch, { status, artifactSha256: a.sha256, summary: { total: cached.entry?.total ?? null, cached: true, artifact: node_path__WEBPACK_IMPORTED_MODULE_2__.basename(a.file) }, now: now() })) onEvent({ type: 'done', repo: job.repoId, cached: true, status });
|
|
1623
|
+
continue;
|
|
1624
|
+
}
|
|
1625
|
+
const ac = new AbortController();
|
|
1626
|
+
const onAbort = () => ac.abort();
|
|
1627
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
1628
|
+
let lost = false;
|
|
1629
|
+
const hb = setInterval(() => { if (!store.heartbeat(job.jobId, job.epoch, { now: now(), leaseMs })) { lost = true; ac.abort(); } }, heartbeatMs);
|
|
1630
|
+
try {
|
|
1631
|
+
const out = await scan(repo.path, { timeoutMs: job.timeoutMs, signal: ac.signal });
|
|
1632
|
+
clearInterval(hb);
|
|
1633
|
+
if (lost) { onEvent({ type: 'fenced', repo: job.repoId }); continue; }
|
|
1634
|
+
if (!out?.entry) throw Object.assign(new Error('the scan returned no entry'), { code: 'ERROR' });
|
|
1635
|
+
if (out.entry.ok === false) throw Object.assign(new Error(out.entry.error || 'the scan failed'), { code: 'ERROR' });
|
|
1636
|
+
const a = writeArtifact(job, { entry: out.entry, findings: out.findings ?? [], findingsTruncated: !!out.findingsTruncated });
|
|
1637
|
+
const status = out.entry.scanHealth && out.entry.scanHealth.status !== 'complete' ? 'partial' : 'succeeded';
|
|
1638
|
+
if (store.complete(job.jobId, job.epoch, { status, artifactSha256: a.sha256, summary: { total: out.entry.total ?? null, cached: false, artifact: node_path__WEBPACK_IMPORTED_MODULE_2__.basename(a.file) }, now: now() })) {
|
|
1639
|
+
if (useCache && status === 'succeeded') (0,_scan_cache_js__WEBPACK_IMPORTED_MODULE_7__/* .writeCache */ .aR)(cacheD, job.cacheKey, { entry: out.entry, findings: out.findings ?? [], findingsTruncated: !!out.findingsTruncated });
|
|
1640
|
+
onEvent({ type: 'done', repo: job.repoId, cached: false, status });
|
|
1641
|
+
} else onEvent({ type: 'fenced', repo: job.repoId });
|
|
1642
|
+
} catch (e) {
|
|
1643
|
+
clearInterval(hb);
|
|
1644
|
+
if (lost || e?.code === 'CANCELLED') { if (e?.code === 'CANCELLED' && !lost) store.fail(job.jobId, job.epoch, { error: 'cancelled', now: now(), retryDelayMs: 0 }); continue; }
|
|
1645
|
+
const r = store.fail(job.jobId, job.epoch, { error: e?.message ?? String(e), timedOut: e?.code === 'TIMEOUT', now: now(), retryDelayMs });
|
|
1646
|
+
onEvent({ type: 'failed', repo: job.repoId, error: e?.message ?? String(e), timedOut: e?.code === 'TIMEOUT', next: r?.state ?? null });
|
|
1647
|
+
} finally { clearInterval(hb); signal?.removeEventListener('abort', onAbort); }
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(workers, 32)) }, (_, i) => worker(i)));
|
|
1651
|
+
return { aborted: !!signal?.aborted };
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
/** Load the artifacts the jobs point at, verifying each against the hash the store recorded. A missing or altered artifact is reported, not used. */
|
|
1655
|
+
function loadArtifacts({ store, runId, root, artifactDir = null }) {
|
|
1656
|
+
const dir = artifactDir ?? (0,_state_dir_js__WEBPACK_IMPORTED_MODULE_6__.statePath)(root, 'fleet', 'artifacts', runId);
|
|
1657
|
+
const out = new Map(); const problems = [];
|
|
1658
|
+
for (const j of store.listJobs(runId)) {
|
|
1659
|
+
if (!['succeeded', 'partial'].includes(j.status)) continue;
|
|
1660
|
+
const file = node_path__WEBPACK_IMPORTED_MODULE_2__.join(dir, j.summary?.artifact ?? `${j.repoId}.${j.epoch}.json`);
|
|
1661
|
+
try {
|
|
1662
|
+
const text = node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(file, 'utf8');
|
|
1663
|
+
if (sha(text) !== j.artifactSha256) { problems.push({ repoId: j.repoId, problem: 'artifact does not match the recorded hash' }); continue; }
|
|
1664
|
+
out.set(j.repoId, JSON.parse(text));
|
|
1665
|
+
} catch { problems.push({ repoId: j.repoId, problem: 'artifact is missing or unreadable' }); }
|
|
1666
|
+
}
|
|
1667
|
+
return { artifacts: out, problems };
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
__webpack_async_result__();
|
|
1671
|
+
} catch(e) { __webpack_async_result__(e); } });
|
|
1672
|
+
|
|
1673
|
+
/***/ }),
|
|
1674
|
+
|
|
1675
|
+
/***/ 20121:
|
|
1676
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
1677
|
+
|
|
1678
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
1679
|
+
/* harmony export */ Hd: () => (/* binding */ isTerminal),
|
|
1680
|
+
/* harmony export */ J6: () => (/* binding */ createSqliteJobStore)
|
|
1681
|
+
/* harmony export */ });
|
|
1682
|
+
/* unused harmony exports JOB_STATUSES, createMemoryJobStore */
|
|
1683
|
+
/* harmony import */ var node_fs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(73024);
|
|
1684
|
+
/* harmony import */ var node_path__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(76760);
|
|
1685
|
+
// The fleet job store (PRD 4, requirements 2 and 3): jobs with a lease, a heartbeat, a retry count, a timeout and a result hash,
|
|
1686
|
+
// behind one small interface so the storage can change without the scheduler noticing.
|
|
1687
|
+
//
|
|
1688
|
+
// createSqliteJobStore({ file }) local deployment, one file, no server (node:sqlite)
|
|
1689
|
+
// createMemoryJobStore() the reference implementation; the conformance tests run the SAME assertions against both,
|
|
1690
|
+
// which is what shows the interface does not secretly depend on SQLite. A PostgreSQL or a
|
|
1691
|
+
// queue-backed store would be a third implementation of the same methods.
|
|
1692
|
+
//
|
|
1693
|
+
// Semantics that matter (and are tested on both stores):
|
|
1694
|
+
// * IDEMPOTENT: a job's id is derived from its run, repository, commit and configuration, and `enqueue` of an existing id does
|
|
1695
|
+
// nothing. Enqueueing a run twice does not make two jobs.
|
|
1696
|
+
// * LEASED: `claim` hands one queued job to one worker, increments a fencing `epoch`, and starts a lease. A worker that stops
|
|
1697
|
+
// heartbeating loses the job when the lease expires; `reclaimExpired` puts it back in the queue.
|
|
1698
|
+
// * FENCED: `heartbeat`, `complete` and `fail` must present the epoch they were given. A worker that lost its lease (and whose
|
|
1699
|
+
// job another worker now holds) is refused, so a slow zombie can neither extend a lease it no longer has nor overwrite the
|
|
1700
|
+
// result of the worker that replaced it. That is what keeps a retry from duplicating findings.
|
|
1701
|
+
// * BOUNDED RETRIES: a failure puts the job back in the queue until `maxAttempts`, then it is `failed` (or `timed_out`),
|
|
1702
|
+
// terminal, and visible in the organization's health. A job is never silently dropped.
|
|
1703
|
+
|
|
1704
|
+
|
|
1705
|
+
|
|
1706
|
+
const JOB_STATUSES = Object.freeze(['queued', 'leased', 'succeeded', 'partial', 'failed', 'timed_out', 'cancelled']);
|
|
1707
|
+
const TERMINAL = new Set(['succeeded', 'partial', 'failed', 'timed_out', 'cancelled']);
|
|
1708
|
+
const isTerminal = (s) => TERMINAL.has(s);
|
|
1709
|
+
|
|
1710
|
+
const jobOf = (r) => (r ? {
|
|
1711
|
+
jobId: r.job_id, runId: r.run_id, repoId: r.repo_id, commit: r.commit_sha ?? null, cacheKey: r.cache_key ?? null, status: r.status, worker: r.worker ?? null,
|
|
1712
|
+
epoch: r.epoch, leaseExpiresAt: r.lease_expires_at ?? null, heartbeatAt: r.heartbeat_at ?? null, attempts: r.attempts, maxAttempts: r.max_attempts,
|
|
1713
|
+
timeoutMs: r.timeout_ms, nextAttemptAt: r.next_attempt_at ?? 0, artifactSha256: r.artifact_sha256 ?? null, summary: r.summary ? JSON.parse(r.summary) : null,
|
|
1714
|
+
error: r.error ?? null, createdAt: r.created_at, startedAt: r.started_at ?? null, finishedAt: r.finished_at ?? null, required: !!r.required,
|
|
1715
|
+
} : null);
|
|
1716
|
+
|
|
1717
|
+
// ------------------------------------------------------------------------------------------------------- memory
|
|
1718
|
+
function createMemoryJobStore() {
|
|
1719
|
+
const runs = new Map(); const jobs = new Map();
|
|
1720
|
+
const all = (runId) => [...jobs.values()].filter((j) => j.runId === runId);
|
|
1721
|
+
const store = {
|
|
1722
|
+
kind: 'memory',
|
|
1723
|
+
begin(run) { if (!runs.has(run.runId)) runs.set(run.runId, { ...run }); return runs.get(run.runId); },
|
|
1724
|
+
getRun: (runId) => runs.get(runId) ?? null,
|
|
1725
|
+
listRuns: () => [...runs.values()].sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt))),
|
|
1726
|
+
enqueue(runId, list, { now = Date.now() } = {}) {
|
|
1727
|
+
let created = 0;
|
|
1728
|
+
for (const j of list) { if (jobs.has(j.jobId)) continue; jobs.set(j.jobId, { runId, repoId: j.repoId, jobId: j.jobId, commit: j.commit ?? null, cacheKey: j.cacheKey ?? null, status: 'queued', worker: null, epoch: 0, leaseExpiresAt: null, heartbeatAt: null, attempts: 0, maxAttempts: j.maxAttempts ?? 2, timeoutMs: j.timeoutMs, nextAttemptAt: 0, artifactSha256: null, summary: null, error: null, createdAt: now, startedAt: null, finishedAt: null, required: j.required !== false }); created++; }
|
|
1729
|
+
return created;
|
|
1730
|
+
},
|
|
1731
|
+
claim(runId, { worker, leaseMs, now = Date.now() }) {
|
|
1732
|
+
const j = all(runId).filter((x) => x.status === 'queued' && x.nextAttemptAt <= now).sort((a, b) => a.createdAt - b.createdAt || a.repoId.localeCompare(b.repoId))[0];
|
|
1733
|
+
if (!j) return null;
|
|
1734
|
+
Object.assign(j, { status: 'leased', worker, epoch: j.epoch + 1, attempts: j.attempts + 1, leaseExpiresAt: now + leaseMs, heartbeatAt: now, startedAt: now, error: null });
|
|
1735
|
+
return { ...j };
|
|
1736
|
+
},
|
|
1737
|
+
heartbeat(jobId, epoch, { now = Date.now(), leaseMs }) { const j = jobs.get(jobId); if (!j || j.status !== 'leased' || j.epoch !== epoch) return false; j.heartbeatAt = now; j.leaseExpiresAt = now + leaseMs; return true; },
|
|
1738
|
+
complete(jobId, epoch, { status = 'succeeded', artifactSha256 = null, summary = null, now = Date.now() }) {
|
|
1739
|
+
const j = jobs.get(jobId); if (!j || j.status !== 'leased' || j.epoch !== epoch) return false;
|
|
1740
|
+
Object.assign(j, { status, artifactSha256, summary, finishedAt: now, leaseExpiresAt: null, error: null }); return true;
|
|
1741
|
+
},
|
|
1742
|
+
fail(jobId, epoch, { error, timedOut = false, now = Date.now(), retryDelayMs = 0 }) {
|
|
1743
|
+
const j = jobs.get(jobId); if (!j || j.status !== 'leased' || j.epoch !== epoch) return null;
|
|
1744
|
+
j.error = String(error ?? '').slice(0, 500); j.leaseExpiresAt = null; j.worker = null;
|
|
1745
|
+
if (j.attempts < j.maxAttempts) { j.status = 'queued'; j.nextAttemptAt = now + retryDelayMs; return { state: 'queued', attempts: j.attempts }; }
|
|
1746
|
+
j.status = timedOut ? 'timed_out' : 'failed'; j.finishedAt = now; return { state: j.status, attempts: j.attempts };
|
|
1747
|
+
},
|
|
1748
|
+
reclaimExpired(runId, { now = Date.now() }) { let n = 0; for (const j of all(runId)) if (j.status === 'leased' && j.leaseExpiresAt != null && j.leaseExpiresAt <= now) { j.status = 'queued'; j.worker = null; j.leaseExpiresAt = null; j.nextAttemptAt = 0; j.error = 'lease expired'; n++; } return n; },
|
|
1749
|
+
reclaimWorker(runId, worker) { let n = 0; for (const j of all(runId)) if (j.status === 'leased' && j.worker === worker) { j.status = 'queued'; j.worker = null; j.leaseExpiresAt = null; j.nextAttemptAt = 0; j.error = 'worker is gone'; n++; } return n; },
|
|
1750
|
+
cancel(runId, { now = Date.now() }) { let n = 0; for (const j of all(runId)) if (!TERMINAL.has(j.status)) { j.status = 'cancelled'; j.finishedAt = now; j.leaseExpiresAt = null; n++; } return n; },
|
|
1751
|
+
retime(runId, timeoutMs) { let n = 0; for (const j of all(runId)) if (j.status === 'queued') { j.timeoutMs = timeoutMs; n++; } return n; },
|
|
1752
|
+
requeueTerminal(runId, statuses) { let n = 0; for (const j of all(runId)) if (statuses.includes(j.status)) { Object.assign(j, { status: 'queued', worker: null, attempts: 0, nextAttemptAt: 0, leaseExpiresAt: null, finishedAt: null, error: null }); n++; } return n; },
|
|
1753
|
+
listJobs: (runId) => all(runId).map((j) => ({ ...j })).sort((a, b) => a.repoId.localeCompare(b.repoId)),
|
|
1754
|
+
getJob: (jobId) => (jobs.get(jobId) ? { ...jobs.get(jobId) } : null),
|
|
1755
|
+
close() {},
|
|
1756
|
+
};
|
|
1757
|
+
return store;
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
// ------------------------------------------------------------------------------------------------------- sqlite
|
|
1761
|
+
const SCHEMA = `
|
|
1762
|
+
CREATE TABLE IF NOT EXISTS fleet_runs (run_id TEXT PRIMARY KEY, organization TEXT NOT NULL, manifest_hash TEXT NOT NULL, config_hash TEXT NOT NULL, scanner_identity TEXT, created_at INTEGER NOT NULL, meta TEXT);
|
|
1763
|
+
CREATE TABLE IF NOT EXISTS fleet_jobs (
|
|
1764
|
+
job_id TEXT PRIMARY KEY, run_id TEXT NOT NULL REFERENCES fleet_runs(run_id), repo_id TEXT NOT NULL, commit_sha TEXT, cache_key TEXT,
|
|
1765
|
+
status TEXT NOT NULL, worker TEXT, epoch INTEGER NOT NULL DEFAULT 0, lease_expires_at INTEGER, heartbeat_at INTEGER,
|
|
1766
|
+
attempts INTEGER NOT NULL DEFAULT 0, max_attempts INTEGER NOT NULL, timeout_ms INTEGER NOT NULL, next_attempt_at INTEGER NOT NULL DEFAULT 0,
|
|
1767
|
+
artifact_sha256 TEXT, summary TEXT, error TEXT, created_at INTEGER NOT NULL, started_at INTEGER, finished_at INTEGER, required INTEGER NOT NULL DEFAULT 1
|
|
1768
|
+
);
|
|
1769
|
+
CREATE INDEX IF NOT EXISTS fleet_jobs_run_status ON fleet_jobs (run_id, status);
|
|
1770
|
+
`;
|
|
1771
|
+
|
|
1772
|
+
async function createSqliteJobStore({ file }) {
|
|
1773
|
+
const { DatabaseSync } = await Promise.resolve(/* import() */).then(__webpack_require__.t.bind(__webpack_require__, 80099, 19));
|
|
1774
|
+
if (file !== ':memory:') node_fs__WEBPACK_IMPORTED_MODULE_0__.mkdirSync(node_path__WEBPACK_IMPORTED_MODULE_1__.dirname(file), { recursive: true, mode: 0o700 });
|
|
1775
|
+
const db = new DatabaseSync(file);
|
|
1776
|
+
db.exec('PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000; PRAGMA foreign_keys = ON;');
|
|
1777
|
+
db.exec(SCHEMA);
|
|
1778
|
+
const tx = (fn) => { db.exec('BEGIN IMMEDIATE'); try { const r = fn(); db.exec('COMMIT'); return r; } catch (e) { try { db.exec('ROLLBACK'); } catch { /* already rolled back */ } throw e; } };
|
|
1779
|
+
const q = (sql, ...p) => db.prepare(sql).all(...p);
|
|
1780
|
+
const one = (sql, ...p) => db.prepare(sql).get(...p);
|
|
1781
|
+
const run = (sql, ...p) => db.prepare(sql).run(...p);
|
|
1782
|
+
return {
|
|
1783
|
+
kind: 'sqlite', file,
|
|
1784
|
+
begin(r) { run('INSERT OR IGNORE INTO fleet_runs (run_id, organization, manifest_hash, config_hash, scanner_identity, created_at, meta) VALUES (?,?,?,?,?,?,?)', r.runId, r.organization, r.manifestHash, r.configHash, r.scannerIdentity ?? null, r.createdAt, JSON.stringify(r.meta ?? {})); return this.getRun(r.runId); },
|
|
1785
|
+
getRun(runId) { const r = one('SELECT * FROM fleet_runs WHERE run_id = ?', runId); return r ? { runId: r.run_id, organization: r.organization, manifestHash: r.manifest_hash, configHash: r.config_hash, scannerIdentity: r.scanner_identity, createdAt: r.created_at, meta: JSON.parse(r.meta ?? '{}') } : null; },
|
|
1786
|
+
listRuns() { return q('SELECT * FROM fleet_runs ORDER BY created_at DESC').map((r) => ({ runId: r.run_id, organization: r.organization, manifestHash: r.manifest_hash, createdAt: r.created_at })); },
|
|
1787
|
+
enqueue(runId, list, { now = Date.now() } = {}) {
|
|
1788
|
+
return tx(() => { let created = 0; for (const j of list) { const r = run('INSERT OR IGNORE INTO fleet_jobs (job_id, run_id, repo_id, commit_sha, cache_key, status, max_attempts, timeout_ms, created_at, required) VALUES (?,?,?,?,?,?,?,?,?,?)', j.jobId, runId, j.repoId, j.commit ?? null, j.cacheKey ?? null, 'queued', j.maxAttempts ?? 2, j.timeoutMs, now, j.required === false ? 0 : 1); created += Number(r.changes); } return created; });
|
|
1789
|
+
},
|
|
1790
|
+
claim(runId, { worker, leaseMs, now = Date.now() }) {
|
|
1791
|
+
return tx(() => {
|
|
1792
|
+
const j = one("SELECT job_id FROM fleet_jobs WHERE run_id = ? AND status = 'queued' AND next_attempt_at <= ? ORDER BY created_at, repo_id LIMIT 1", runId, now);
|
|
1793
|
+
if (!j) return null;
|
|
1794
|
+
run("UPDATE fleet_jobs SET status = 'leased', worker = ?, epoch = epoch + 1, attempts = attempts + 1, lease_expires_at = ?, heartbeat_at = ?, started_at = ?, error = NULL WHERE job_id = ?", worker, now + leaseMs, now, now, j.job_id);
|
|
1795
|
+
return jobOf(one('SELECT * FROM fleet_jobs WHERE job_id = ?', j.job_id));
|
|
1796
|
+
});
|
|
1797
|
+
},
|
|
1798
|
+
heartbeat(jobId, epoch, { now = Date.now(), leaseMs }) { return Number(run("UPDATE fleet_jobs SET heartbeat_at = ?, lease_expires_at = ? WHERE job_id = ? AND status = 'leased' AND epoch = ?", now, now + leaseMs, jobId, epoch).changes) === 1; },
|
|
1799
|
+
complete(jobId, epoch, { status = 'succeeded', artifactSha256 = null, summary = null, now = Date.now() }) { return Number(run("UPDATE fleet_jobs SET status = ?, artifact_sha256 = ?, summary = ?, finished_at = ?, lease_expires_at = NULL, error = NULL WHERE job_id = ? AND status = 'leased' AND epoch = ?", status, artifactSha256, summary ? JSON.stringify(summary) : null, now, jobId, epoch).changes) === 1; },
|
|
1800
|
+
fail(jobId, epoch, { error, timedOut = false, now = Date.now(), retryDelayMs = 0 }) {
|
|
1801
|
+
return tx(() => {
|
|
1802
|
+
const j = one("SELECT attempts, max_attempts FROM fleet_jobs WHERE job_id = ? AND status = 'leased' AND epoch = ?", jobId, epoch);
|
|
1803
|
+
if (!j) return null;
|
|
1804
|
+
const msg = String(error ?? '').slice(0, 500);
|
|
1805
|
+
if (j.attempts < j.max_attempts) { run("UPDATE fleet_jobs SET status = 'queued', worker = NULL, lease_expires_at = NULL, next_attempt_at = ?, error = ? WHERE job_id = ?", now + retryDelayMs, msg, jobId); return { state: 'queued', attempts: j.attempts }; }
|
|
1806
|
+
const st = timedOut ? 'timed_out' : 'failed';
|
|
1807
|
+
run('UPDATE fleet_jobs SET status = ?, worker = NULL, lease_expires_at = NULL, finished_at = ?, error = ? WHERE job_id = ?', st, now, msg, jobId);
|
|
1808
|
+
return { state: st, attempts: j.attempts };
|
|
1809
|
+
});
|
|
1810
|
+
},
|
|
1811
|
+
reclaimExpired(runId, { now = Date.now() }) { return Number(run("UPDATE fleet_jobs SET status = 'queued', worker = NULL, lease_expires_at = NULL, next_attempt_at = 0, error = 'lease expired' WHERE run_id = ? AND status = 'leased' AND lease_expires_at IS NOT NULL AND lease_expires_at <= ?", runId, now).changes); },
|
|
1812
|
+
reclaimWorker(runId, worker) { return Number(run("UPDATE fleet_jobs SET status = 'queued', worker = NULL, lease_expires_at = NULL, next_attempt_at = 0, error = 'worker is gone' WHERE run_id = ? AND status = 'leased' AND worker = ?", runId, worker).changes); },
|
|
1813
|
+
cancel(runId, { now = Date.now() }) { return Number(run("UPDATE fleet_jobs SET status = 'cancelled', finished_at = ?, lease_expires_at = NULL WHERE run_id = ? AND status IN ('queued','leased')", now, runId).changes); },
|
|
1814
|
+
retime(runId, timeoutMs) { return Number(run("UPDATE fleet_jobs SET timeout_ms = ? WHERE run_id = ? AND status = 'queued'", timeoutMs, runId).changes); },
|
|
1815
|
+
requeueTerminal(runId, statuses) { return Number(run(`UPDATE fleet_jobs SET status = 'queued', worker = NULL, attempts = 0, next_attempt_at = 0, lease_expires_at = NULL, finished_at = NULL, error = NULL WHERE run_id = ? AND status IN (${statuses.map(() => '?').join(',')})`, runId, ...statuses).changes); },
|
|
1816
|
+
listJobs: (runId) => q('SELECT * FROM fleet_jobs WHERE run_id = ? ORDER BY repo_id', runId).map(jobOf),
|
|
1817
|
+
getJob: (jobId) => jobOf(one('SELECT * FROM fleet_jobs WHERE job_id = ?', jobId)),
|
|
1818
|
+
close() { try { db.close(); } catch { /* already closed */ } },
|
|
1819
|
+
};
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
|
|
1823
|
+
/***/ }),
|
|
1824
|
+
|
|
1825
|
+
/***/ 75435:
|
|
1826
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
1827
|
+
|
|
1828
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
1829
|
+
/* harmony export */ Vp: () => (/* binding */ renderOrgHealth),
|
|
1830
|
+
/* harmony export */ WB: () => (/* binding */ computeOrgHealth)
|
|
1831
|
+
/* harmony export */ });
|
|
1832
|
+
/* unused harmony export ASSURANCE */
|
|
1833
|
+
/* harmony import */ var _job_store_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(20121);
|
|
1834
|
+
// Organization-level scan health (PRD 4, requirement 9). Pure.
|
|
1835
|
+
//
|
|
1836
|
+
// Repositories requested: 1,250
|
|
1837
|
+
// Complete: 1,228 Partial: 11 Failed: 6 Timed out: 5 Not run: ...
|
|
1838
|
+
//
|
|
1839
|
+
// The one rule: an organization is not GREEN while a required repository has no usable result. A repository that was requested but
|
|
1840
|
+
// has no job, was cancelled, is still queued or leased, failed or timed out is counted under its own heading and can never disappear
|
|
1841
|
+
// from the total. `partial` (the scan finished but the engine itself reported a degraded scan) keeps the organization out of GREEN
|
|
1842
|
+
// as well, because a partial scan is not evidence of a clean repository.
|
|
1843
|
+
//
|
|
1844
|
+
// green every required repository has a complete result and no optional repository has a problem
|
|
1845
|
+
// degraded every required repository has a result, but some result is partial, or an optional repository has a problem
|
|
1846
|
+
// red at least one required repository has no result
|
|
1847
|
+
|
|
1848
|
+
|
|
1849
|
+
const ASSURANCE = Object.freeze(['green', 'degraded', 'red']);
|
|
1850
|
+
|
|
1851
|
+
function computeOrgHealth({ manifest, jobs }) {
|
|
1852
|
+
const byRepo = new Map(jobs.map((j) => [j.repoId, j]));
|
|
1853
|
+
const counts = { requested: manifest.repositories.length, complete: 0, partial: 0, failed: 0, timedOut: 0, cancelled: 0, notRun: 0 };
|
|
1854
|
+
const repos = [];
|
|
1855
|
+
for (const r of manifest.repositories) {
|
|
1856
|
+
const j = byRepo.get(r.id);
|
|
1857
|
+
let state;
|
|
1858
|
+
if (!j) state = 'not-run';
|
|
1859
|
+
else if (j.status === 'succeeded') state = 'complete';
|
|
1860
|
+
else if (j.status === 'partial') state = 'partial';
|
|
1861
|
+
else if (j.status === 'failed') state = 'failed';
|
|
1862
|
+
else if (j.status === 'timed_out') state = 'timed-out';
|
|
1863
|
+
else if (j.status === 'cancelled') state = 'cancelled';
|
|
1864
|
+
else state = 'not-run'; // queued or leased: no result yet
|
|
1865
|
+
counts[{ complete: 'complete', partial: 'partial', failed: 'failed', 'timed-out': 'timedOut', cancelled: 'cancelled', 'not-run': 'notRun' }[state]]++;
|
|
1866
|
+
repos.push({ id: r.id, required: r.required, state, attempts: j?.attempts ?? 0, error: j?.error ?? null, cached: !!j?.summary?.cached, findings: j?.summary?.total ?? null, jobStatus: j?.status ?? null, finished: j ? (0,_job_store_js__WEBPACK_IMPORTED_MODULE_0__/* .isTerminal */ .Hd)(j.status) : false });
|
|
1867
|
+
}
|
|
1868
|
+
const noResult = (x) => !['complete', 'partial'].includes(x.state);
|
|
1869
|
+
const requiredMissing = repos.filter((x) => x.required && noResult(x));
|
|
1870
|
+
const requiredPartial = repos.filter((x) => x.required && x.state === 'partial');
|
|
1871
|
+
const optionalProblems = repos.filter((x) => !x.required && x.state !== 'complete');
|
|
1872
|
+
const assurance = requiredMissing.length ? 'red' : (requiredPartial.length || optionalProblems.length) ? 'degraded' : 'green';
|
|
1873
|
+
const reasons = [
|
|
1874
|
+
...(requiredMissing.length ? [`${requiredMissing.length} required ${requiredMissing.length === 1 ? 'repository has' : 'repositories have'} no result: ${requiredMissing.slice(0, 8).map((x) => `${x.id} (${x.state})`).join(', ')}${requiredMissing.length > 8 ? ', ...' : ''}`] : []),
|
|
1875
|
+
...(requiredPartial.length ? [`${requiredPartial.length} required ${requiredPartial.length === 1 ? 'repository was' : 'repositories were'} only partially scanned: ${requiredPartial.map((x) => x.id).join(', ')}`] : []),
|
|
1876
|
+
...(optionalProblems.length ? [`${optionalProblems.length} optional ${optionalProblems.length === 1 ? 'repository has' : 'repositories have'} a problem: ${optionalProblems.map((x) => `${x.id} (${x.state})`).join(', ')}`] : []),
|
|
1877
|
+
];
|
|
1878
|
+
return { counts, assurance, reasons, repositories: repos };
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
const n = (x) => x.toLocaleString('en-US');
|
|
1882
|
+
function renderOrgHealth(h) {
|
|
1883
|
+
const c = h.counts;
|
|
1884
|
+
const L = [`Repositories requested: ${n(c.requested)}`, `Complete: ${n(c.complete)}`, `Partial: ${n(c.partial)}`, `Failed: ${n(c.failed)}`, `Timed out: ${n(c.timedOut)}`];
|
|
1885
|
+
if (c.cancelled) L.push(`Cancelled: ${n(c.cancelled)}`);
|
|
1886
|
+
if (c.notRun) L.push(`Not run: ${n(c.notRun)}`);
|
|
1887
|
+
L.push('', `Assurance: ${h.assurance.toUpperCase()}`, ...h.reasons.map((r) => ` ${r}`));
|
|
1888
|
+
return L.join('\n');
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
|
|
1892
|
+
/***/ }),
|
|
1893
|
+
|
|
1894
|
+
/***/ 75846:
|
|
1895
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
1896
|
+
|
|
1897
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
1898
|
+
/* harmony export */ NJ: () => (/* binding */ repoFingerprint),
|
|
1899
|
+
/* harmony export */ aR: () => (/* binding */ writeCache),
|
|
1900
|
+
/* harmony export */ fs: () => (/* binding */ scannerIdentity),
|
|
1901
|
+
/* harmony export */ k$: () => (/* binding */ cacheKey),
|
|
1902
|
+
/* harmony export */ xe: () => (/* binding */ readCache)
|
|
1903
|
+
/* harmony export */ });
|
|
1904
|
+
/* harmony import */ var node_fs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(73024);
|
|
1905
|
+
/* harmony import */ var node_path__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(76760);
|
|
1906
|
+
/* harmony import */ var node_crypto__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(77598);
|
|
1907
|
+
/* harmony import */ var node_child_process__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(31421);
|
|
1908
|
+
/* harmony import */ var node_url__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(73136);
|
|
1909
|
+
/* harmony import */ var _state_dir_js__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(31174);
|
|
1910
|
+
// The content-addressed scan cache (PRD 4, requirement 5). A repository's scan result is reused only when EVERYTHING that could
|
|
1911
|
+
// change it is unchanged: the commit (and any uncommitted change), the lockfiles, the scan configuration and the scanner itself.
|
|
1912
|
+
//
|
|
1913
|
+
// key = sha256( repository fingerprint, lockfile digests, configuration, scanner identity )
|
|
1914
|
+
//
|
|
1915
|
+
// Anything that cannot be determined makes the key `null`, and a null key means "do not cache": an unknown input must never be
|
|
1916
|
+
// treated as an unchanged one. The scanner identity is the package version plus the SHA-256 recorded for the shipped bundle, so a
|
|
1917
|
+
// change to scanner logic changes the key. A source checkout with no built bundle has no such identity and therefore never reuses
|
|
1918
|
+
// a cached result.
|
|
1919
|
+
//
|
|
1920
|
+
// A cached record carries its own digest; one that was edited, truncated or does not match its key is a miss, never a hit.
|
|
1921
|
+
|
|
1922
|
+
|
|
1923
|
+
|
|
1924
|
+
|
|
1925
|
+
|
|
1926
|
+
|
|
1927
|
+
|
|
1928
|
+
const LOCKFILES = Object.freeze(['package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock', 'pnpm-lock.yaml', 'go.sum', 'Cargo.lock', 'Pipfile.lock', 'poetry.lock', 'requirements.txt', 'Gemfile.lock', 'composer.lock', 'flake.lock', 'cabal.project.freeze', 'stack.yaml.lock']);
|
|
1929
|
+
const SKIP = new Set(['.git', 'node_modules', _state_dir_js__WEBPACK_IMPORTED_MODULE_5__/* .STATE_DIR_NAME */ .Ky]);
|
|
1930
|
+
const sha = (s) => (0,node_crypto__WEBPACK_IMPORTED_MODULE_2__.createHash)('sha256').update(s).digest('hex');
|
|
1931
|
+
const MAX_FILES = 5000; const MAX_FILE_BYTES = 2 * 1024 * 1024; const MAX_TOTAL_BYTES = 128 * 1024 * 1024;
|
|
1932
|
+
|
|
1933
|
+
function git(repo, args, timeout = 10000) {
|
|
1934
|
+
const r = (0,node_child_process__WEBPACK_IMPORTED_MODULE_3__.spawnSync)('git', ['-C', repo, ...args], { encoding: 'utf8', timeout, killSignal: 'SIGKILL', maxBuffer: 32 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
1935
|
+
return r.error || r.status !== 0 ? null : r.stdout;
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
function treeDigest(root) {
|
|
1939
|
+
const h = (0,node_crypto__WEBPACK_IMPORTED_MODULE_2__.createHash)('sha256'); let files = 0; let total = 0; let complete = true;
|
|
1940
|
+
const walk = (d, rel) => {
|
|
1941
|
+
let ents = [];
|
|
1942
|
+
try { ents = node_fs__WEBPACK_IMPORTED_MODULE_0__.readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); } catch { complete = false; return; }
|
|
1943
|
+
for (const e of ents) {
|
|
1944
|
+
if (!complete) return;
|
|
1945
|
+
if (SKIP.has(e.name)) continue;
|
|
1946
|
+
const p = node_path__WEBPACK_IMPORTED_MODULE_1__.join(d, e.name); const r = rel ? `${rel}/${e.name}` : e.name;
|
|
1947
|
+
if (e.isSymbolicLink()) { h.update(`L ${r}\0`); continue; }
|
|
1948
|
+
if (e.isDirectory()) { walk(p, r); continue; }
|
|
1949
|
+
if (!e.isFile()) continue;
|
|
1950
|
+
if (++files > MAX_FILES) { complete = false; return; }
|
|
1951
|
+
let st; try { st = node_fs__WEBPACK_IMPORTED_MODULE_0__.statSync(p); } catch { complete = false; return; }
|
|
1952
|
+
total += st.size;
|
|
1953
|
+
if (total > MAX_TOTAL_BYTES) { complete = false; return; }
|
|
1954
|
+
if (st.size > MAX_FILE_BYTES) { h.update(`B ${r} ${st.size}\0`); continue; }
|
|
1955
|
+
try { h.update(`F ${r}\0${sha(node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(p))}\0`); } catch { complete = false; return; }
|
|
1956
|
+
}
|
|
1957
|
+
};
|
|
1958
|
+
walk(root, '');
|
|
1959
|
+
return complete ? h.digest('hex') : null;
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
/** What identifies this repository's content right now, or null parts where it cannot be determined. */
|
|
1963
|
+
function repoFingerprint(repoPath) {
|
|
1964
|
+
const head = git(repoPath, ['rev-parse', 'HEAD']);
|
|
1965
|
+
const commit = head ? head.trim() : null;
|
|
1966
|
+
let content = null; let dirty = null;
|
|
1967
|
+
if (commit) {
|
|
1968
|
+
const status = git(repoPath, ['status', '--porcelain=v1', '-uall']);
|
|
1969
|
+
dirty = status == null ? null : status.trim().length > 0;
|
|
1970
|
+
if (dirty === false) content = `git:${commit}`;
|
|
1971
|
+
else if (dirty === true) { const t = treeDigest(repoPath); content = t ? `git+worktree:${commit}:${t}` : null; }
|
|
1972
|
+
} else { const t = treeDigest(repoPath); content = t ? `tree:${t}` : null; }
|
|
1973
|
+
const lock = {};
|
|
1974
|
+
for (const f of LOCKFILES) { try { const st = node_fs__WEBPACK_IMPORTED_MODULE_0__.statSync(node_path__WEBPACK_IMPORTED_MODULE_1__.join(repoPath, f)); if (st.isFile() && st.size <= 32 * 1024 * 1024) lock[f] = sha(node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(node_path__WEBPACK_IMPORTED_MODULE_1__.join(repoPath, f))); } catch { /* absent */ } }
|
|
1975
|
+
return { commit, dirty, content, lockfiles: lock };
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
/** Package version + the bundle's recorded hash. Null when the scanner cannot be identified (a source checkout), which disables the cache. */
|
|
1979
|
+
function scannerIdentity() {
|
|
1980
|
+
try {
|
|
1981
|
+
const here = node_path__WEBPACK_IMPORTED_MODULE_1__.dirname((0,node_url__WEBPACK_IMPORTED_MODULE_4__.fileURLToPath)(import.meta.url));
|
|
1982
|
+
for (const base of [node_path__WEBPACK_IMPORTED_MODULE_1__.join(here, '..', '..', '..'), node_path__WEBPACK_IMPORTED_MODULE_1__.join(here, '..', '..', '..', '..')]) {
|
|
1983
|
+
const pkg = node_path__WEBPACK_IMPORTED_MODULE_1__.join(base, 'package.json');
|
|
1984
|
+
const sidecar = node_path__WEBPACK_IMPORTED_MODULE_1__.join(base, 'dist', 'agentic-security.mjs.sha256');
|
|
1985
|
+
if (node_fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(pkg) && node_fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(sidecar)) {
|
|
1986
|
+
const version = JSON.parse(node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(pkg, 'utf8')).version;
|
|
1987
|
+
const digest = node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(sidecar, 'utf8').trim().split(/\s+/)[0];
|
|
1988
|
+
if (version && /^[0-9a-f]{64}$/i.test(digest)) return `${version}+${digest}`;
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
} catch { /* unknown */ }
|
|
1992
|
+
return null;
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
function cacheKey({ fingerprint, config, identity }) {
|
|
1996
|
+
if (!fingerprint?.content || !identity) return null;
|
|
1997
|
+
return sha(JSON.stringify({ c: fingerprint.content, l: fingerprint.lockfiles, cfg: config, id: identity }));
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
function readCache(dir, key) {
|
|
2001
|
+
if (!key || !/^[0-9a-f]{64}$/.test(key)) return null;
|
|
2002
|
+
try {
|
|
2003
|
+
const doc = JSON.parse(node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(node_path__WEBPACK_IMPORTED_MODULE_1__.join(dir, `${key}.json`), 'utf8'));
|
|
2004
|
+
if (doc.key !== key) return null;
|
|
2005
|
+
const { digest, ...rest } = doc;
|
|
2006
|
+
return digest === sha(JSON.stringify(rest)) ? doc : null;
|
|
2007
|
+
} catch { return null; }
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
function writeCache(dir, key, payload) {
|
|
2011
|
+
if (!key || !/^[0-9a-f]{64}$/.test(key)) return false;
|
|
2012
|
+
try {
|
|
2013
|
+
node_fs__WEBPACK_IMPORTED_MODULE_0__.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
2014
|
+
const body = { schema: 'agentic-security/fleet-scan-cache', version: 1, key, ...payload };
|
|
2015
|
+
const doc = { ...body, digest: sha(JSON.stringify(body)) };
|
|
2016
|
+
const tmp = node_path__WEBPACK_IMPORTED_MODULE_1__.join(dir, `.${key}.${process.pid}.tmp`);
|
|
2017
|
+
node_fs__WEBPACK_IMPORTED_MODULE_0__.writeFileSync(tmp, JSON.stringify(doc), { mode: 0o600 });
|
|
2018
|
+
node_fs__WEBPACK_IMPORTED_MODULE_0__.renameSync(tmp, node_path__WEBPACK_IMPORTED_MODULE_1__.join(dir, `${key}.json`));
|
|
2019
|
+
return true;
|
|
2020
|
+
} catch { return false; }
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
|
|
2024
|
+
/***/ }),
|
|
2025
|
+
|
|
2026
|
+
/***/ 3398:
|
|
2027
|
+
/***/ ((__webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
2028
|
+
|
|
2029
|
+
__webpack_require__.a(__webpack_module__, async (__webpack_handle_async_dependencies__, __webpack_async_result__) => { try {
|
|
2030
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
2031
|
+
/* harmony export */ K: () => (/* binding */ END),
|
|
2032
|
+
/* harmony export */ Q: () => (/* binding */ BEGIN)
|
|
2033
|
+
/* harmony export */ });
|
|
2034
|
+
/* harmony import */ var _fleet_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(80313);
|
|
2035
|
+
/* harmony import */ var _runScan_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(45950);
|
|
2036
|
+
var __webpack_async_dependencies__ = __webpack_handle_async_dependencies__([_runScan_js__WEBPACK_IMPORTED_MODULE_1__]);
|
|
2037
|
+
_runScan_js__WEBPACK_IMPORTED_MODULE_1__ = (__webpack_async_dependencies__.then ? (await __webpack_async_dependencies__)() : __webpack_async_dependencies__)[0];
|
|
2038
|
+
// Child-process entry for ONE repository's scan inside a fleet run (PRD 4, requirement 4: one hung repository must not stop the
|
|
2039
|
+
// fleet). `node scan-child.js <repoPath>`. It exists as its own process so the controller can kill a scan that does not finish,
|
|
2040
|
+
// together with everything the scan started; an in-process scan cannot be interrupted.
|
|
2041
|
+
//
|
|
2042
|
+
// It runs the scan through the existing `runFleet` for a single repository, so the per-repository entry (finding counts, owners
|
|
2043
|
+
// from CODEOWNERS, scan health, policy drift, provenance debt) is exactly what a fleet entry has always been, and prints that entry
|
|
2044
|
+
// plus a slim copy of the findings between markers. Scan state writes are disabled (`AGENTIC_SECURITY_NO_STATE=1`, set by the
|
|
2045
|
+
// controller): scanning a repository for the fleet must not change that repository.
|
|
2046
|
+
|
|
2047
|
+
|
|
2048
|
+
|
|
2049
|
+
const BEGIN = '@@FLEET-SCAN-BEGIN@@';
|
|
2050
|
+
const END = '@@FLEET-SCAN-END@@';
|
|
2051
|
+
const MAX_FINDINGS = 5000;
|
|
2052
|
+
|
|
2053
|
+
const slim = (f) => ({ id: f.id ?? null, stableId: f.stableId ?? null, severity: f.severity ?? null, cwe: f.cwe ?? null, family: f.family ?? null, vuln: f.vuln ?? null, file: f.file ?? null, line: Number.isInteger(f.line) ? f.line : null, parser: f.parser ?? null });
|
|
2054
|
+
|
|
2055
|
+
async function main(repoPath) {
|
|
2056
|
+
let captured = null;
|
|
2057
|
+
const res = await (0,_fleet_js__WEBPACK_IMPORTED_MODULE_0__/* .runFleet */ .gI)({ repos: [repoPath], concurrency: 1, resume: false, runScan: async (p) => { const r = await (0,_runScan_js__WEBPACK_IMPORTED_MODULE_1__.runScan)(p, { provenance: false }); captured = r.scan; return r; } });
|
|
2058
|
+
const entry = res.results?.[0];
|
|
2059
|
+
if (!entry) throw new Error('the scan produced no entry');
|
|
2060
|
+
const findings = (captured?.findings ?? []).slice(0, MAX_FINDINGS).map(slim);
|
|
2061
|
+
process.stdout.write(`${BEGIN}${JSON.stringify({ entry, findings, findingsTruncated: (captured?.findings?.length ?? 0) > MAX_FINDINGS })}${END}`);
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) {
|
|
2065
|
+
main(process.argv[2]).then(() => process.exit(0), (e) => { process.stderr.write(String(e?.stack || e).slice(0, 4000)); process.exit(1); });
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
__webpack_async_result__();
|
|
2069
|
+
} catch(e) { __webpack_async_result__(e); } });
|
|
2070
|
+
|
|
2071
|
+
/***/ }),
|
|
2072
|
+
|
|
2073
|
+
/***/ 23736:
|
|
2074
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
2075
|
+
|
|
2076
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
2077
|
+
/* harmony export */ buildPolicyBundle: () => (/* binding */ buildPolicyBundle),
|
|
2078
|
+
/* harmony export */ ensurePolicyKeyPair: () => (/* binding */ ensurePolicyKeyPair),
|
|
2079
|
+
/* harmony export */ loadPolicyBundles: () => (/* binding */ loadPolicyBundles),
|
|
2080
|
+
/* harmony export */ loadPolicyPublicKey: () => (/* binding */ loadPolicyPublicKey),
|
|
2081
|
+
/* harmony export */ rE: () => (/* binding */ computePolicyDrift),
|
|
2082
|
+
/* harmony export */ resolveEffectivePolicy: () => (/* binding */ resolveEffectivePolicy),
|
|
2083
|
+
/* harmony export */ signPolicyBundle: () => (/* binding */ signPolicyBundle)
|
|
2084
|
+
/* harmony export */ });
|
|
2085
|
+
/* unused harmony exports POLICY_BUNDLE_SCHEMA, SCOPES, canonicalPolicyBytes, verifyPolicyBundle */
|
|
2086
|
+
/* harmony import */ var node_fs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(73024);
|
|
2087
|
+
/* harmony import */ var node_os__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(48161);
|
|
2088
|
+
/* harmony import */ var node_path__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(76760);
|
|
2089
|
+
/* harmony import */ var node_crypto__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(77598);
|
|
2090
|
+
/* harmony import */ var _evidence_bundle_js__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(98317);
|
|
2091
|
+
/* harmony import */ var _state_dir_js__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(31174);
|
|
2092
|
+
// Signed, portable policy bundles (assurance-hardening PRD FR-1001).
|
|
2093
|
+
//
|
|
2094
|
+
// "Support signed portable policy bundles with organization, repository, and
|
|
2095
|
+
// environment inheritance" | "Tampered or expired policy is rejected;
|
|
2096
|
+
// effective policy is explainable."
|
|
2097
|
+
//
|
|
2098
|
+
// WHY REUSE evidence-bundle.js's Ed25519 SCHEME, NOT integrity.js's HMAC
|
|
2099
|
+
// -------------------------------------------------------------------------
|
|
2100
|
+
// A policy bundle is authored by one party (typically a central security
|
|
2101
|
+
// team, "the organization") and distributed to many repositories that never
|
|
2102
|
+
// had the signing key. integrity.js's per-install symmetric HMAC is
|
|
2103
|
+
// tamper-evidence for the SAME install that wrote it — a different install
|
|
2104
|
+
// verifying a bundle it didn't sign is exactly the case that scheme cannot
|
|
2105
|
+
// serve. evidence-bundle.js already solved this for findings: Ed25519,
|
|
2106
|
+
// verify with only the public key. This module reuses that primitive
|
|
2107
|
+
// directly (canonicalisation shape, allowlist-signed-fields discipline,
|
|
2108
|
+
// exclusive-create key generation) rather than inventing a third signing
|
|
2109
|
+
// mechanism, per this session's own established "survey before building a
|
|
2110
|
+
// parallel primitive" practice (see D-0023/D-0025 for the same discipline
|
|
2111
|
+
// applied to identity and exception mechanisms respectively).
|
|
2112
|
+
//
|
|
2113
|
+
// EA-03 LESSON, APPLIED HERE FROM THE START
|
|
2114
|
+
// -------------------------------------------------------------------------
|
|
2115
|
+
// evidence-bundle.js's own history (see its EA-03 comment) is the reason
|
|
2116
|
+
// `canonicalPolicyBytes` signs an explicit ALLOWLIST and `verifyPolicyBundle`
|
|
2117
|
+
// REJECTS any top-level key outside it: a bundle with a field stapled on
|
|
2118
|
+
// after signing must fail verification, not silently pass with the addition
|
|
2119
|
+
// unverified. Getting this right on the first pass — rather than discovering
|
|
2120
|
+
// the gap the way evidence-bundle.js did — is the entire point of writing it
|
|
2121
|
+
// down here.
|
|
2122
|
+
//
|
|
2123
|
+
// SCOPE — WHAT THIS MODULE DOES NOT DO
|
|
2124
|
+
// -------------------------------------------------------------------------
|
|
2125
|
+
// It does not invent a policy SCHEMA — `policy` is an opaque object; whatever
|
|
2126
|
+
// keys a bundle carries (egress rules, severity floors, approved providers,
|
|
2127
|
+
// anything else this repo already treats as policy) are merged the same way.
|
|
2128
|
+
// It does not implement deep/recursive merging — inheritance is shallow,
|
|
2129
|
+
// top-level-key override (organization -> repository -> environment, most
|
|
2130
|
+
// specific wins per key), matching how every other flat policy config file
|
|
2131
|
+
// in this repo (egress-policy.yml, compliance-severity-policy.json, …) is
|
|
2132
|
+
// already shaped. A future consumer needing nested-key inheritance is new,
|
|
2133
|
+
// separate scope, not assumed here.
|
|
2134
|
+
|
|
2135
|
+
|
|
2136
|
+
|
|
2137
|
+
|
|
2138
|
+
|
|
2139
|
+
|
|
2140
|
+
|
|
2141
|
+
|
|
2142
|
+
const POLICY_BUNDLE_SCHEMA = 'agentic-security/policy-bundle@1';
|
|
2143
|
+
|
|
2144
|
+
const SCOPES = ['organization', 'repository', 'environment'];
|
|
2145
|
+
// Inheritance order — later entries override earlier ones, per top-level key.
|
|
2146
|
+
const INHERITANCE_ORDER = ['organization', 'repository', 'environment'];
|
|
2147
|
+
|
|
2148
|
+
function keyDir() {
|
|
2149
|
+
const xdg = process.env.XDG_CONFIG_HOME || node_path__WEBPACK_IMPORTED_MODULE_2__.join(node_os__WEBPACK_IMPORTED_MODULE_1__.homedir(), '.config');
|
|
2150
|
+
return node_path__WEBPACK_IMPORTED_MODULE_2__.join(xdg, 'agentic-security', 'policy-bundles');
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
/** A SEPARATE Ed25519 keypair from evidence-bundle.js's finding-attestation
|
|
2154
|
+
* key — signing "this policy is authentic" and "this finding really came
|
|
2155
|
+
* from a scan run by me" are different trust domains and must not share a
|
|
2156
|
+
* key. Reuses evidence-bundle.js's hardened, race-safe generation logic
|
|
2157
|
+
* directly (its exclusive-create collision handling is the kind of code
|
|
2158
|
+
* worth NOT re-implementing) rather than duplicating it — the separation
|
|
2159
|
+
* that matters is the DIRECTORY (`policy-bundles/`, isolated from
|
|
2160
|
+
* evidence-bundle.js's own key dir), not the leaf filenames, which are
|
|
2161
|
+
* `attest-key.pem`/`attest-key.pub.pem` regardless of caller (a
|
|
2162
|
+
* evidence-bundle.js internal, not overridable here) — a readability wart,
|
|
2163
|
+
* not a security one, since the directory is what prevents key reuse. */
|
|
2164
|
+
function ensurePolicyKeyPair(dir = keyDir()) {
|
|
2165
|
+
return (0,_evidence_bundle_js__WEBPACK_IMPORTED_MODULE_4__.ensureKeyPair)(dir);
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
/**
|
|
2169
|
+
* Deterministic JSON, keys sorted at every level — identical algorithm to
|
|
2170
|
+
* evidence-bundle.js's canonicalJson (duplicated rather than imported: it is
|
|
2171
|
+
* a pure, three-line function, and importing it would couple this module's
|
|
2172
|
+
* signature format to evidence-bundle.js's internals for no real benefit).
|
|
2173
|
+
*/
|
|
2174
|
+
function canonicalJson(value) {
|
|
2175
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value ?? null);
|
|
2176
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
|
2177
|
+
const keys = Object.keys(value).sort();
|
|
2178
|
+
return `{${keys.map(k => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(',')}}`;
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
// The complete set of fields a legitimately-built bundle carries, BEFORE
|
|
2182
|
+
// signing. This is the signed allowlist AND (in verifyPolicyBundle) the
|
|
2183
|
+
// complete set of keys a signed bundle is permitted to have alongside
|
|
2184
|
+
// `signature` — see the EA-03 note above for why both matter.
|
|
2185
|
+
const BUNDLE_FIELDS = ['schema', 'scope', 'policy', 'issuedAt', 'expiresAt'];
|
|
2186
|
+
const BUNDLE_TOP_LEVEL_KEYS = new Set([...BUNDLE_FIELDS, 'signature']);
|
|
2187
|
+
|
|
2188
|
+
function canonicalPolicyBytes(bundle) {
|
|
2189
|
+
const signed = {};
|
|
2190
|
+
for (const k of BUNDLE_FIELDS) signed[k] = bundle[k] ?? null;
|
|
2191
|
+
return Buffer.from(canonicalJson(signed), 'utf8');
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
/**
|
|
2195
|
+
* Build an unsigned bundle. `policy` is caller-supplied and opaque — this
|
|
2196
|
+
* module does not validate its shape, only its provenance and freshness.
|
|
2197
|
+
*/
|
|
2198
|
+
function buildPolicyBundle(scope, policy, { issuedAt, expiresAt } = {}) {
|
|
2199
|
+
if (!SCOPES.includes(scope)) return null;
|
|
2200
|
+
if (!policy || typeof policy !== 'object' || Array.isArray(policy)) return null;
|
|
2201
|
+
return {
|
|
2202
|
+
schema: POLICY_BUNDLE_SCHEMA,
|
|
2203
|
+
scope,
|
|
2204
|
+
policy,
|
|
2205
|
+
issuedAt: issuedAt ?? new Date().toISOString(),
|
|
2206
|
+
expiresAt: expiresAt ?? null,
|
|
2207
|
+
};
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
/** Sign a bundle. Returns a new object; the input is not mutated. */
|
|
2211
|
+
function signPolicyBundle(bundle, privateKeyPem) {
|
|
2212
|
+
const sig = node_crypto__WEBPACK_IMPORTED_MODULE_3__.sign(null, canonicalPolicyBytes(bundle), privateKeyPem);
|
|
2213
|
+
return {
|
|
2214
|
+
...bundle,
|
|
2215
|
+
signature: { algorithm: 'ed25519', canonicalisation: POLICY_BUNDLE_SCHEMA, value: sig.toString('base64') },
|
|
2216
|
+
};
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
/**
|
|
2220
|
+
* Verify a bundle: signature authenticity AND freshness. Never throws — a
|
|
2221
|
+
* malformed or tampered bundle from an untrusted source (a repo checking out
|
|
2222
|
+
* whatever an org distributed) is an expected input, not exceptional.
|
|
2223
|
+
*
|
|
2224
|
+
* @returns {{ok: boolean, reason: string|null}}
|
|
2225
|
+
*/
|
|
2226
|
+
function verifyPolicyBundle(bundle, publicKeyPem, { now = Date.now() } = {}) {
|
|
2227
|
+
if (!bundle || typeof bundle !== 'object') return { ok: false, reason: 'bundle is not an object' };
|
|
2228
|
+
if (bundle.schema !== POLICY_BUNDLE_SCHEMA) return { ok: false, reason: `unrecognised schema: ${bundle.schema}` };
|
|
2229
|
+
if (!SCOPES.includes(bundle.scope)) return { ok: false, reason: `unrecognised scope: ${bundle.scope}` };
|
|
2230
|
+
const unknownKeys = Object.keys(bundle).filter(k => !BUNDLE_TOP_LEVEL_KEYS.has(k));
|
|
2231
|
+
if (unknownKeys.length) {
|
|
2232
|
+
return { ok: false, reason: `unrecognised top-level key(s) not covered by the signature: ${unknownKeys.join(', ')}` };
|
|
2233
|
+
}
|
|
2234
|
+
const sig = bundle.signature;
|
|
2235
|
+
if (!sig?.value) return { ok: false, reason: 'bundle is unsigned' };
|
|
2236
|
+
if (sig.algorithm !== 'ed25519') return { ok: false, reason: `unsupported algorithm: ${sig.algorithm}` };
|
|
2237
|
+
if (!publicKeyPem) return { ok: false, reason: 'no public key supplied' };
|
|
2238
|
+
let sigOk = false;
|
|
2239
|
+
try {
|
|
2240
|
+
sigOk = node_crypto__WEBPACK_IMPORTED_MODULE_3__.verify(null, canonicalPolicyBytes(bundle), publicKeyPem, Buffer.from(sig.value, 'base64'));
|
|
2241
|
+
} catch (e) {
|
|
2242
|
+
return { ok: false, reason: `verification error: ${e.message}` };
|
|
2243
|
+
}
|
|
2244
|
+
if (!sigOk) return { ok: false, reason: 'signature does not match the bundle contents — it was modified after signing' };
|
|
2245
|
+
if (bundle.expiresAt) {
|
|
2246
|
+
const exp = Date.parse(bundle.expiresAt);
|
|
2247
|
+
if (!Number.isFinite(exp)) return { ok: false, reason: `expiresAt is not a valid date: ${bundle.expiresAt}` };
|
|
2248
|
+
if (exp < now) return { ok: false, reason: `policy expired on ${bundle.expiresAt}` };
|
|
2249
|
+
}
|
|
2250
|
+
return { ok: true, reason: null };
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
/**
|
|
2254
|
+
* Merge verified bundles into one effective policy, in inheritance order
|
|
2255
|
+
* (organization -> repository -> environment; most specific wins per key).
|
|
2256
|
+
* A tampered or expired bundle is EXCLUDED from the merge (rejected, not
|
|
2257
|
+
* silently dropped — its scope and reason are reported so a rejection is
|
|
2258
|
+
* visible, not just absent). `provenance[key]` names which scope's bundle
|
|
2259
|
+
* last set that key — the "effective policy is explainable" half of the
|
|
2260
|
+
* acceptance criterion.
|
|
2261
|
+
*
|
|
2262
|
+
* @param {Array<{scope: string, bundle: object}>} entries
|
|
2263
|
+
* @param {string} publicKeyPem
|
|
2264
|
+
* @returns {{effective: object, provenance: Record<string,string>,
|
|
2265
|
+
* accepted: string[], rejected: Array<{scope:string, reason:string}>}}
|
|
2266
|
+
*/
|
|
2267
|
+
function resolveEffectivePolicy(entries, publicKeyPem, { now = Date.now() } = {}) {
|
|
2268
|
+
const byScope = new Map();
|
|
2269
|
+
for (const e of entries || []) {
|
|
2270
|
+
if (e && SCOPES.includes(e.scope)) byScope.set(e.scope, e.bundle);
|
|
2271
|
+
}
|
|
2272
|
+
const effective = {};
|
|
2273
|
+
const provenance = {};
|
|
2274
|
+
const accepted = [];
|
|
2275
|
+
const rejected = [];
|
|
2276
|
+
for (const scope of INHERITANCE_ORDER) {
|
|
2277
|
+
const bundle = byScope.get(scope);
|
|
2278
|
+
if (!bundle) continue;
|
|
2279
|
+
const v = verifyPolicyBundle(bundle, publicKeyPem, { now });
|
|
2280
|
+
if (!v.ok) { rejected.push({ scope, reason: v.reason }); continue; }
|
|
2281
|
+
accepted.push(scope);
|
|
2282
|
+
for (const [k, val] of Object.entries(bundle.policy)) {
|
|
2283
|
+
effective[k] = val;
|
|
2284
|
+
provenance[k] = scope;
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
return { effective, provenance, accepted, rejected };
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
/**
|
|
2291
|
+
* Load whichever policy bundle files exist under
|
|
2292
|
+
* `.agentic-security/policy-bundles/{organization,repository,environment}.json`.
|
|
2293
|
+
* Degrades gracefully — a missing directory or missing/malformed individual
|
|
2294
|
+
* file is simply absent from the result, never thrown. Read-first-in-try/
|
|
2295
|
+
* catch throughout (no existsSync-then-readFileSync — D-0012/D-0022).
|
|
2296
|
+
*/
|
|
2297
|
+
function loadPolicyBundles(scanRoot) {
|
|
2298
|
+
if (!scanRoot) return [];
|
|
2299
|
+
const entries = [];
|
|
2300
|
+
for (const scope of SCOPES) {
|
|
2301
|
+
let fp;
|
|
2302
|
+
try { fp = (0,_state_dir_js__WEBPACK_IMPORTED_MODULE_5__.statePath)(scanRoot, 'policy-bundles', `${scope}.json`); } catch { continue; }
|
|
2303
|
+
let raw;
|
|
2304
|
+
try { raw = node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(fp, 'utf8'); } catch { continue; }
|
|
2305
|
+
try {
|
|
2306
|
+
const bundle = JSON.parse(raw);
|
|
2307
|
+
if (bundle && typeof bundle === 'object') entries.push({ scope, bundle });
|
|
2308
|
+
} catch { /* malformed — skip, do not throw */ }
|
|
2309
|
+
}
|
|
2310
|
+
return entries;
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
/**
|
|
2314
|
+
* Read the operator-supplied public key an org distributes alongside its
|
|
2315
|
+
* bundles, from `.agentic-security/policy-bundle-public-key.pem`. Returns
|
|
2316
|
+
* null (not a throw) if absent or unreadable — resolution then rejects
|
|
2317
|
+
* every bundle for lack of a key, same as any other "no public key
|
|
2318
|
+
* supplied" case in verifyPolicyBundle.
|
|
2319
|
+
*/
|
|
2320
|
+
function loadPolicyPublicKey(scanRoot) {
|
|
2321
|
+
if (!scanRoot) return null;
|
|
2322
|
+
let fp;
|
|
2323
|
+
try { fp = (0,_state_dir_js__WEBPACK_IMPORTED_MODULE_5__.statePath)(scanRoot, 'policy-bundle-public-key.pem'); } catch { return null; }
|
|
2324
|
+
try { return node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(fp, 'utf8'); } catch { return null; }
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
/**
|
|
2328
|
+
* FR-1006 ("policy drift"): does this repository's LOCALLY resolved effective
|
|
2329
|
+
* policy diverge from what the organization-scope bundle alone specifies —
|
|
2330
|
+
* and is anything the repo is relying on actually being rejected (tampered
|
|
2331
|
+
* or expired) rather than enforced? Both are GOVERNANCE gaps, distinct from
|
|
2332
|
+
* risk findings, which is the exact distinction FR-1006's acceptance
|
|
2333
|
+
* criterion asks fleet output to make.
|
|
2334
|
+
*
|
|
2335
|
+
* No bundles configured at all is a no-op (`null`), matching this session's
|
|
2336
|
+
* established convention: drift can only be reported relative to a
|
|
2337
|
+
* baseline, and a repo with no organization bundle has no baseline to drift
|
|
2338
|
+
* from.
|
|
2339
|
+
*
|
|
2340
|
+
* @returns {{accepted: string[], rejected: Array<{scope,reason}>,
|
|
2341
|
+
* overrides: Array<{key, organizationValue, effectiveValue, overriddenBy}>}
|
|
2342
|
+
* | null}
|
|
2343
|
+
*/
|
|
2344
|
+
function computePolicyDrift(scanRoot) {
|
|
2345
|
+
const entries = loadPolicyBundles(scanRoot);
|
|
2346
|
+
if (!entries.length) return null;
|
|
2347
|
+
const publicKeyPem = loadPolicyPublicKey(scanRoot);
|
|
2348
|
+
const { effective, provenance, accepted, rejected } = resolveEffectivePolicy(entries, publicKeyPem);
|
|
2349
|
+
|
|
2350
|
+
const overrides = [];
|
|
2351
|
+
const orgEntry = entries.find(e => e.scope === 'organization');
|
|
2352
|
+
if (orgEntry && accepted.includes('organization')) {
|
|
2353
|
+
for (const [key, organizationValue] of Object.entries(orgEntry.bundle.policy || {})) {
|
|
2354
|
+
const overriddenBy = provenance[key];
|
|
2355
|
+
if (overriddenBy && overriddenBy !== 'organization' && !_deepEqual(effective[key], organizationValue)) {
|
|
2356
|
+
overrides.push({ key, organizationValue, effectiveValue: effective[key], overriddenBy });
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
return { accepted, rejected, overrides };
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
function _deepEqual(a, b) {
|
|
2364
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
|
|
2368
|
+
/***/ })
|
|
2369
|
+
|
|
2370
|
+
};
|