@clear-capabilities/agentic-security-scanner 0.172.0 → 0.173.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 +29 -0
- package/bin/agentic-security.js +37 -0
- package/dist/119.index.js +197 -0
- package/dist/128.index.js +129 -0
- package/dist/1293.index.js +5689 -0
- package/dist/1336.index.js +101 -101
- package/dist/1351.index.js +133 -0
- package/dist/1387.index.js +271 -0
- package/dist/1437.index.js +23 -0
- package/dist/1445.index.js +110 -0
- package/dist/1505.index.js +151 -0
- package/dist/1557.index.js +293 -0
- package/dist/1621.index.js +311 -0
- package/dist/1751.index.js +160 -0
- package/dist/1765.index.js +727 -0
- package/dist/1861.index.js +690 -0
- package/dist/1898.index.js +116 -0
- package/dist/1906.index.js +433 -0
- package/dist/1920.index.js +1 -291
- package/dist/208.index.js +1631 -0
- package/dist/2199.index.js +0 -239
- package/dist/2223.index.js +159 -0
- package/dist/2530.index.js +152 -0
- package/dist/2565.index.js +116 -0
- package/dist/2574.index.js +127 -0
- package/dist/2620.index.js +345 -0
- package/dist/2773.index.js +98 -0
- package/dist/2775.index.js +32 -0
- package/dist/2966.index.js +171 -0
- package/dist/2972.index.js +87 -0
- package/dist/2991.index.js +131 -0
- package/dist/3063.index.js +833 -0
- package/dist/3075.index.js +227 -0
- package/dist/3121.index.js +297 -0
- package/dist/3314.index.js +263 -0
- package/dist/3480.index.js +1822 -0
- package/dist/3481.index.js +24 -0
- package/dist/3506.index.js +268 -0
- package/dist/3533.index.js +591 -0
- package/dist/3653.index.js +450 -0
- package/dist/3822.index.js +131 -0
- package/dist/3868.index.js +292 -0
- package/dist/3917.index.js +152 -0
- package/dist/3918.index.js +192 -0
- package/dist/4090.index.js +288 -0
- package/dist/4121.index.js +1190 -0
- package/dist/4142.index.js +0 -239
- package/dist/4336.index.js +436 -0
- package/dist/4387.index.js +532 -0
- package/dist/4413.index.js +302 -0
- package/dist/4476.index.js +4510 -0
- package/dist/4491.index.js +133 -0
- package/dist/4538.index.js +185 -0
- package/dist/4684.index.js +319 -0
- package/dist/4740.index.js +165 -0
- package/dist/4780.index.js +209 -0
- package/dist/4802.index.js +225 -0
- package/dist/4812.index.js +379 -0
- package/dist/4835.index.js +126 -0
- package/dist/494.index.js +226 -0
- package/dist/4991.index.js +549 -0
- package/dist/5199.index.js +305 -0
- package/dist/5216.index.js +109 -0
- package/dist/5439.index.js +301 -0
- package/dist/5443.index.js +97 -0
- package/dist/5496.index.js +377 -0
- package/dist/550.index.js +2 -1
- package/dist/5801.index.js +588 -0
- package/dist/5858.index.js +223 -0
- package/dist/5933.index.js +143 -0
- package/dist/601.index.js +23 -0
- package/dist/6173.index.js +273 -0
- package/dist/6291.index.js +1871 -0
- package/dist/6312.index.js +666 -0
- package/dist/634.index.js +16182 -0
- package/dist/644.index.js +87 -0
- package/dist/6508.index.js +283 -0
- package/dist/6560.index.js +645 -0
- package/dist/6576.index.js +422 -0
- package/dist/6579.index.js +271 -0
- package/dist/6745.index.js +305 -0
- package/dist/6818.index.js +21642 -0
- package/dist/6972.index.js +167 -0
- package/dist/6995.index.js +163 -0
- package/dist/7249.index.js +191 -0
- package/dist/7372.index.js +1197 -0
- package/dist/7405.index.js +23 -6
- package/dist/7546.index.js +541 -0
- package/dist/76.index.js +448 -0
- package/dist/7879.index.js +163 -0
- package/dist/8025.index.js +130 -0
- package/dist/8054.index.js +248 -0
- package/dist/8097.index.js +201 -0
- package/dist/8126.index.js +191 -0
- package/dist/8178.index.js +78 -0
- package/dist/818.index.js +212 -0
- package/dist/8382.index.js +93 -0
- package/dist/8408.index.js +261 -0
- package/dist/8540.index.js +174 -0
- package/dist/8635.index.js +802 -0
- package/dist/8688.index.js +410 -0
- package/dist/8699.index.js +779 -0
- package/dist/8891.index.js +457 -0
- package/dist/8916.index.js +885 -0
- package/dist/9146.index.js +170 -0
- package/dist/9168.index.js +841 -0
- package/dist/9292.index.js +369 -0
- package/dist/9382.index.js +338 -48
- package/dist/9422.index.js +866 -0
- package/dist/9440.index.js +453 -0
- package/dist/9479.index.js +152 -0
- package/dist/9481.index.js +220 -0
- package/dist/9539.index.js +100 -0
- package/dist/9547.index.js +103 -0
- package/dist/9560.index.js +41 -61
- package/dist/9692.index.js +90 -0
- package/dist/9800.index.js +213 -1
- package/dist/9899.index.js +138 -0
- package/dist/9981.index.js +206 -0
- package/dist/999.index.js +117 -0
- package/dist/agentic-security.mjs +28 -28
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +31 -6
- package/src/context-intelligence/CLAUDE.md +4 -0
- package/src/context-intelligence/adapters/haskell-hie.js +28 -1
- package/src/context-intelligence/candidates.js +2 -1
- package/src/context-intelligence/canonical.js +25 -0
- package/src/context-intelligence/capabilities.js +2 -1
- package/src/context-intelligence/cli.js +16 -10
- package/src/context-intelligence/config.js +4 -1
- package/src/context-intelligence/controls.js +108 -10
- package/src/context-intelligence/counterexample.js +200 -0
- package/src/context-intelligence/cross-route.js +111 -0
- package/src/context-intelligence/entrypoints.js +2 -2
- package/src/context-intelligence/evaluation/index.js +11 -4
- package/src/context-intelligence/evaluation/scripted-reader.js +12 -2
- package/src/context-intelligence/findings.js +166 -0
- package/src/context-intelligence/invariants.js +197 -0
- package/src/context-intelligence/queue.js +6 -1
- package/src/context-intelligence/reachability.js +10 -4
- package/src/context-intelligence/review.js +129 -16
- package/src/dataflow/access-paths.js +11 -0
- package/src/dataflow/analysis-limits.js +66 -0
- package/src/dataflow/engine.js +272 -20
- package/src/dataflow/service-edges.js +174 -0
- package/src/dataflow/summaries.js +7 -1
- package/src/discovery/judge.js +2 -1
- package/src/engine.js +82 -14
- package/src/fix/apply-fix-service.js +55 -8
- package/src/fix/batch-journal.js +84 -0
- package/src/fix/exploit-replay.js +51 -0
- package/src/fix/footprint.js +65 -0
- package/src/fix/patch-policy.js +49 -0
- package/src/fix/secret-persistence.js +39 -0
- package/src/fix/test-commands.js +84 -0
- package/src/fix/verification-adapters.js +97 -0
- package/src/fix/verification-record.js +243 -0
- package/src/fix/verify-patch.js +89 -0
- package/src/ir/CLAUDE.md +5 -0
- package/src/ir/callgraph.js +23 -1
- package/src/ir/module-resolver.js +315 -0
- package/src/ir/parser-js.js +21 -2
- package/src/ir/semantic-graph.js +245 -0
- package/src/lineage/build-attested-correlation.js +63 -0
- package/src/lineage/deployment-identity.js +76 -0
- package/src/mcp/tools.js +13 -34
- package/src/pipeline/canonical-output.js +64 -0
- package/src/pipeline/detector-runner.js +7 -0
- package/src/pipeline/scan-health.js +14 -0
- package/src/pipeline/supervised-analyzers.js +125 -0
- package/src/pipeline/supervised-child.js +17 -0
- package/src/pipeline/telemetry.js +79 -0
- package/src/posture/accuracy-scorecard.js +98 -9
- package/src/posture/artifact-registry.js +5 -0
- package/src/posture/assurance/evidence-registry.js +215 -0
- package/src/posture/assurance/release-package-verify.js +135 -0
- package/src/posture/assurance/release-package.js +118 -0
- package/src/posture/assurance/release-verdict.js +94 -0
- package/src/posture/assurance/waiver.js +200 -0
- package/src/posture/compliance-truth.js +100 -0
- package/src/posture/evaluation/scan-child.js +3 -1
- package/src/posture/evaluation/why-missed.js +3 -1
- package/src/posture/exploitability-evidence.js +141 -0
- package/src/posture/fix-verify.js +4 -2
- package/src/posture/fleet/exposure-ranking.js +115 -0
- package/src/posture/fleet/service-chain-graph.js +150 -0
- package/src/posture/graph-reconciliation.js +154 -0
- package/src/posture/provenance/coverage.js +103 -0
- package/src/posture/provenance/git-evidence.js +84 -0
- package/src/posture/provenance/origin-resolver.js +7 -1
- package/src/posture/provenance/predicate-replay.js +14 -4
- package/src/posture/runtime-correlation.js +61 -15
- package/src/posture/runtime-explanation.js +116 -0
- package/src/posture/runtime-trace-ingest.js +194 -0
- package/src/posture/scan-checkpoint.js +38 -10
- package/src/posture/waf-ingest.js +7 -2
- package/src/report/index.js +4 -0
- package/src/report/oscal.js +38 -0
- package/src/report/perspectives.js +150 -0
- package/src/sast/_comment-strip.js +8 -1
- package/src/sast/ruby.js +26 -0
- package/src/util/string-memo.js +38 -0
|
@@ -0,0 +1,1190 @@
|
|
|
1
|
+
export const id = 4121;
|
|
2
|
+
export const ids = [4121,4413];
|
|
3
|
+
export const modules = {
|
|
4
|
+
|
|
5
|
+
/***/ 44121:
|
|
6
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
7
|
+
|
|
8
|
+
// ESM COMPAT FLAG
|
|
9
|
+
__webpack_require__.r(__webpack_exports__);
|
|
10
|
+
|
|
11
|
+
// EXPORTS
|
|
12
|
+
__webpack_require__.d(__webpack_exports__, {
|
|
13
|
+
cmdFleet: () => (/* binding */ cmdFleet)
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// EXTERNAL MODULE: external "node:fs"
|
|
17
|
+
var external_node_fs_ = __webpack_require__(73024);
|
|
18
|
+
// EXTERNAL MODULE: external "node:path"
|
|
19
|
+
var external_node_path_ = __webpack_require__(76760);
|
|
20
|
+
// EXTERNAL MODULE: ./src/util/yaml.js + 1 modules
|
|
21
|
+
var yaml = __webpack_require__(32469);
|
|
22
|
+
;// CONCATENATED MODULE: ./src/posture/fleet/fleet-manifest.js
|
|
23
|
+
// The fleet manifest (PRD 4 "Fleet Manifest"). `fleet.yaml`:
|
|
24
|
+
//
|
|
25
|
+
// organization: example
|
|
26
|
+
// repositories:
|
|
27
|
+
// - id: frontend
|
|
28
|
+
// path: ../frontend
|
|
29
|
+
// workers: 4
|
|
30
|
+
// perRepoTimeout: 20m
|
|
31
|
+
//
|
|
32
|
+
// Parsing reuses util/yaml.js (the one YAML entry point in this codebase). Validation is closed-world: an unknown
|
|
33
|
+
// top-level key, a duplicate repository id, or a path that does not resolve to a real directory all refuse the
|
|
34
|
+
// whole manifest rather than silently skipping one repo.
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
const FLEET_MANIFEST_SCHEMA_KEYS = Object.freeze(['organization', 'repositories', 'workers', 'perRepoTimeout']);
|
|
40
|
+
const DURATION_RE = /^(\d+)(s|m|h)$/;
|
|
41
|
+
|
|
42
|
+
function parseDuration(s, fallbackMs) {
|
|
43
|
+
if (typeof s === 'number' && s > 0) return s * 1000;
|
|
44
|
+
const m = DURATION_RE.exec(String(s ?? ''));
|
|
45
|
+
if (!m) return fallbackMs;
|
|
46
|
+
const n = Number(m[1]);
|
|
47
|
+
return m[2] === 's' ? n * 1000 : m[2] === 'm' ? n * 60000 : n * 3600000;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Validate a parsed manifest object against the manifest's own base directory (for path resolution). */
|
|
51
|
+
function validateFleetManifest(doc, { baseDir = '.' } = {}) {
|
|
52
|
+
const errors = [];
|
|
53
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return { ok: false, errors: ['manifest must be a YAML mapping'] };
|
|
54
|
+
for (const k of Object.keys(doc)) if (!FLEET_MANIFEST_SCHEMA_KEYS.includes(k)) errors.push(`unknown top-level key: ${k}`);
|
|
55
|
+
if (typeof doc.organization !== 'string' || !doc.organization) errors.push('organization is required and must be a string');
|
|
56
|
+
if (!Array.isArray(doc.repositories) || doc.repositories.length === 0) errors.push('repositories must be a non-empty array');
|
|
57
|
+
const seen = new Set();
|
|
58
|
+
const repos = [];
|
|
59
|
+
for (const [i, r] of (Array.isArray(doc.repositories) ? doc.repositories : []).entries()) {
|
|
60
|
+
if (!r || typeof r !== 'object') { errors.push(`repositories[${i}] must be a mapping`); continue; }
|
|
61
|
+
if (typeof r.id !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/.test(r.id)) { errors.push(`repositories[${i}].id must be a lowercase kebab-case string`); continue; }
|
|
62
|
+
if (seen.has(r.id)) { errors.push(`duplicate repository id: ${r.id}`); continue; }
|
|
63
|
+
seen.add(r.id);
|
|
64
|
+
if (typeof r.path !== 'string' || !r.path) { errors.push(`repositories[${i}].path is required`); continue; }
|
|
65
|
+
const abs = external_node_path_.resolve(baseDir, r.path);
|
|
66
|
+
let isDir = false;
|
|
67
|
+
try { isDir = external_node_fs_.statSync(abs).isDirectory(); } catch { /* reported below */ }
|
|
68
|
+
if (!isDir) errors.push(`repositories[${i}].path does not resolve to a directory: ${r.path}`);
|
|
69
|
+
repos.push({ id: r.id, path: abs });
|
|
70
|
+
}
|
|
71
|
+
if (doc.workers != null && !(Number.isInteger(doc.workers) && doc.workers >= 1 && doc.workers <= 32)) errors.push('workers must be an integer in 1..32');
|
|
72
|
+
if (doc.perRepoTimeout != null && parseDuration(doc.perRepoTimeout, null) == null) errors.push(`perRepoTimeout is not a valid duration: ${doc.perRepoTimeout}`);
|
|
73
|
+
if (errors.length) return { ok: false, errors };
|
|
74
|
+
return {
|
|
75
|
+
ok: true, errors: [],
|
|
76
|
+
manifest: {
|
|
77
|
+
organization: doc.organization, repositories: repos,
|
|
78
|
+
workers: doc.workers ?? 4, perRepoTimeoutMs: parseDuration(doc.perRepoTimeout, 20 * 60000),
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function loadFleetManifest(filePath) {
|
|
84
|
+
let text;
|
|
85
|
+
try { text = external_node_fs_.readFileSync(filePath, 'utf8'); } catch (e) { return { ok: false, errors: [`cannot read ${filePath}: ${e.message}`] }; }
|
|
86
|
+
let doc;
|
|
87
|
+
try { doc = (0,yaml/* load */.Hh)(text); } catch (e) { return { ok: false, errors: [`invalid YAML: ${e.message}`] }; }
|
|
88
|
+
return validateFleetManifest(doc, { baseDir: external_node_path_.dirname(external_node_path_.resolve(filePath)) });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function starterManifestYaml(organization = 'example') {
|
|
92
|
+
return `organization: ${organization}
|
|
93
|
+
repositories:
|
|
94
|
+
- id: example-repo
|
|
95
|
+
path: .
|
|
96
|
+
workers: 4
|
|
97
|
+
perRepoTimeout: 20m
|
|
98
|
+
`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
;// CONCATENATED MODULE: ./src/posture/fleet/fleet-graph.js
|
|
102
|
+
// The cross-repository dependency graph for the fleet feature (PRD 4).
|
|
103
|
+
//
|
|
104
|
+
// Reads each repo's own manifest to learn (a) the package name it publishes under, if any, and (b) the package
|
|
105
|
+
// names it depends on. An edge A -> B means "A declares a dependency that resolves to B's own published name" —
|
|
106
|
+
// i.e. B is upstream of A, and a defect in B can affect A. This is a real, lightweight cross-repo dependency graph
|
|
107
|
+
// built purely from manifests already on disk; it does not need deployment config (Kubernetes/compose/Terraform)
|
|
108
|
+
// the way the existing deployment-boundary graph does, so a fleet of plain application repos works out of the box.
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
const MANIFESTS = Object.freeze([
|
|
113
|
+
{ file: 'package.json', ecosystem: 'npm' },
|
|
114
|
+
{ file: 'go.mod', ecosystem: 'go' },
|
|
115
|
+
{ file: 'Cargo.toml', ecosystem: 'cargo' },
|
|
116
|
+
{ file: 'requirements.txt', ecosystem: 'pypi' },
|
|
117
|
+
{ file: 'pyproject.toml', ecosystem: 'pypi' },
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
function readBounded(p, maxBytes = 1024 * 1024) {
|
|
121
|
+
try {
|
|
122
|
+
const st = external_node_fs_.statSync(p);
|
|
123
|
+
if (!st.isFile() || st.size > maxBytes) return null;
|
|
124
|
+
return external_node_fs_.readFileSync(p, 'utf8');
|
|
125
|
+
} catch { return null; }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parsePackageJson(text) {
|
|
129
|
+
let j;
|
|
130
|
+
try { j = JSON.parse(text); } catch { return { name: null, deps: new Set() }; }
|
|
131
|
+
const deps = new Set([...Object.keys(j.dependencies || {}), ...Object.keys(j.devDependencies || {}), ...Object.keys(j.peerDependencies || {})]);
|
|
132
|
+
return { name: typeof j.name === 'string' ? j.name : null, deps };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function parseGoMod(text) {
|
|
136
|
+
const m = /^module\s+(\S+)/m.exec(text);
|
|
137
|
+
const deps = new Set();
|
|
138
|
+
const block = /require\s*\(([\s\S]*?)\)/.exec(text);
|
|
139
|
+
const lines = block ? block[1].split('\n') : text.split('\n').filter((l) => /^require\s/.test(l.trim()));
|
|
140
|
+
for (const raw of lines) {
|
|
141
|
+
const l = raw.replace(/^require\s+/, '').trim();
|
|
142
|
+
const dm = /^(\S+)\s+v?\S+/.exec(l);
|
|
143
|
+
if (dm) deps.add(dm[1]);
|
|
144
|
+
}
|
|
145
|
+
return { name: m ? m[1] : null, deps };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function parseCargoToml(text) {
|
|
149
|
+
const nm = /\[package\][\s\S]*?\bname\s*=\s*"([^"]+)"/.exec(text);
|
|
150
|
+
const deps = new Set();
|
|
151
|
+
const section = /\[dependencies\]([\s\S]*?)(\n\[|$)/.exec(text);
|
|
152
|
+
if (section) {
|
|
153
|
+
for (const line of section[1].split('\n')) {
|
|
154
|
+
const dm = /^\s*([A-Za-z0-9_-]+)\s*=/.exec(line);
|
|
155
|
+
if (dm) deps.add(dm[1]);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return { name: nm ? nm[1] : null, deps };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function parseRequirementsTxt(text) {
|
|
162
|
+
const deps = new Set();
|
|
163
|
+
for (const raw of text.split('\n')) {
|
|
164
|
+
const l = raw.trim();
|
|
165
|
+
if (!l || l.startsWith('#') || l.startsWith('-')) continue;
|
|
166
|
+
const dm = /^([A-Za-z0-9._-]+)/.exec(l);
|
|
167
|
+
if (dm) deps.add(dm[1].toLowerCase());
|
|
168
|
+
}
|
|
169
|
+
return { name: null, deps };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function parsePyprojectToml(text) {
|
|
173
|
+
const nm = /\[project\][\s\S]*?\bname\s*=\s*"([^"]+)"/.exec(text);
|
|
174
|
+
const deps = new Set();
|
|
175
|
+
const section = /dependencies\s*=\s*\[([\s\S]*?)\]/.exec(text);
|
|
176
|
+
if (section) {
|
|
177
|
+
for (const m of section[1].matchAll(/"([A-Za-z0-9._-]+)/g)) deps.add(m[1].toLowerCase());
|
|
178
|
+
}
|
|
179
|
+
return { name: nm ? nm[1] : null, deps };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const PARSERS = { 'package.json': parsePackageJson, 'go.mod': parseGoMod, 'Cargo.toml': parseCargoToml, 'requirements.txt': parseRequirementsTxt, 'pyproject.toml': parsePyprojectToml };
|
|
183
|
+
|
|
184
|
+
/** One repo's declared identity and dependency names, merged across every manifest file it has. */
|
|
185
|
+
function readRepoManifest(repoPath) {
|
|
186
|
+
let name = null;
|
|
187
|
+
const deps = new Set();
|
|
188
|
+
const ecosystems = new Set();
|
|
189
|
+
for (const { file, ecosystem } of MANIFESTS) {
|
|
190
|
+
const text = readBounded(external_node_path_.join(repoPath, file));
|
|
191
|
+
if (text == null) continue;
|
|
192
|
+
const parsed = PARSERS[file](text);
|
|
193
|
+
if (parsed.name && !name) name = parsed.name;
|
|
194
|
+
for (const d of parsed.deps) deps.add(d);
|
|
195
|
+
ecosystems.add(ecosystem);
|
|
196
|
+
}
|
|
197
|
+
return { name, deps, ecosystems: [...ecosystems] };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Build the fleet dependency graph. `repos`: [{id, path}]. An edge {from, to} means `from` depends on `to` (so a
|
|
202
|
+
* defect in `to` can affect `from`). Matching is by declared package NAME only — two repos that happen to share a
|
|
203
|
+
* name under different ecosystems are not disambiguated, a disclosed limit, not a guess.
|
|
204
|
+
*/
|
|
205
|
+
function buildFleetDependencyGraph(repos) {
|
|
206
|
+
const manifests = new Map();
|
|
207
|
+
for (const r of repos) manifests.set(r.id, { ...r, manifest: readRepoManifest(r.path) });
|
|
208
|
+
|
|
209
|
+
const byName = new Map();
|
|
210
|
+
for (const [id, r] of manifests) if (r.manifest.name) {
|
|
211
|
+
if (!byName.has(r.manifest.name)) byName.set(r.manifest.name, []);
|
|
212
|
+
byName.get(r.manifest.name).push(id);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const edges = [];
|
|
216
|
+
for (const [id, r] of manifests) {
|
|
217
|
+
for (const dep of r.manifest.deps) {
|
|
218
|
+
const targets = byName.get(dep);
|
|
219
|
+
if (!targets) continue;
|
|
220
|
+
for (const to of targets) if (to !== id) edges.push({ from: id, to, via: dep });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const nodes = [...manifests.values()].map((r) => ({ id: r.id, path: r.path, declaredName: r.manifest.name, ecosystems: r.manifest.ecosystems }));
|
|
224
|
+
return { nodes, edges };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Transitive downstream closure: which fleet repos depend on `repoId`, directly or transitively, with the shortest path to each. */
|
|
228
|
+
function blastRadius(graph, repoId) {
|
|
229
|
+
const incoming = new Map();
|
|
230
|
+
for (const e of graph.edges) { if (!incoming.has(e.to)) incoming.set(e.to, []); incoming.get(e.to).push(e.from); }
|
|
231
|
+
const best = new Map();
|
|
232
|
+
const queue = [{ id: repoId, path: [repoId] }];
|
|
233
|
+
while (queue.length) {
|
|
234
|
+
const { id, path: p } = queue.shift();
|
|
235
|
+
for (const from of incoming.get(id) || []) {
|
|
236
|
+
if (best.has(from)) continue;
|
|
237
|
+
const newPath = [...p, from];
|
|
238
|
+
best.set(from, { depth: newPath.length - 1, path: newPath });
|
|
239
|
+
queue.push({ id: from, path: newPath });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return [...best.entries()].map(([id, v]) => ({ id, ...v })).sort((a, b) => a.depth - b.depth || (a.id < b.id ? -1 : 1));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// EXTERNAL MODULE: ./src/posture/policy-bundle.js
|
|
246
|
+
var policy_bundle = __webpack_require__(14413);
|
|
247
|
+
;// CONCATENATED MODULE: ./src/posture/fleet.js
|
|
248
|
+
// PRD Epic 5 — fleet orchestration.
|
|
249
|
+
//
|
|
250
|
+
// Many repositories, one invocation, one rolled-up result set. The existing
|
|
251
|
+
// `org-scan` could iterate repos; what it could not do is survive an
|
|
252
|
+
// interruption, bound its own concurrency, tell you what is NEW since the last
|
|
253
|
+
// run, or route a finding to the person who owns the file. Those four are the
|
|
254
|
+
// difference between a loop and something an organisation can put on a
|
|
255
|
+
// schedule.
|
|
256
|
+
//
|
|
257
|
+
// THE HARD LINE (PRD §2.2, §10): everything here runs from the CLI on the
|
|
258
|
+
// customer's infrastructure. There is no server, no account, no multi-tenancy,
|
|
259
|
+
// and the "UI" is a static artifact generated from local files. A fleet feature
|
|
260
|
+
// is exactly where a local-first tool drifts into a platform, so the constraint
|
|
261
|
+
// is stated here rather than left to judgement.
|
|
262
|
+
//
|
|
263
|
+
// FOUR PROPERTIES, EACH OF WHICH IS A FAILURE MODE IF ABSENT:
|
|
264
|
+
//
|
|
265
|
+
// 1. ISOLATION — one repo that explodes must not take the run with it. A
|
|
266
|
+
// fleet scan that dies on repo 3 of 50 is worse than useless: it looks
|
|
267
|
+
// like a completed scan of 3 repos.
|
|
268
|
+
// 2. RESUMABILITY — the checkpoint records COMPLETED repos, and is written
|
|
269
|
+
// after each one. A resumed run skips them rather than re-scanning, which
|
|
270
|
+
// is what makes a 50-repo run survivable on a laptop or a flaky runner.
|
|
271
|
+
// 3. BOUNDED CONCURRENCY — an unbounded fan-out over 50 repos will exhaust
|
|
272
|
+
// memory or file handles on the machine it was supposed to help.
|
|
273
|
+
// 4. HONEST ROLL-UP — a repo that FAILED is counted as failed, never folded
|
|
274
|
+
// into "0 findings". Silent zeros are how a fleet report says everything
|
|
275
|
+
// is fine because nothing ran.
|
|
276
|
+
//
|
|
277
|
+
// DELTA IS COMPUTED FROM STABLE IDS, not counts. "3 more criticals than last
|
|
278
|
+
// week" tells you nothing about which; a set difference over stableId tells you
|
|
279
|
+
// exactly what appeared, and is what a scheduled run should notify on.
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
const SCHEMA = 'agentic-security/fleet-state@1';
|
|
286
|
+
|
|
287
|
+
/** Read a checkpoint, or an empty one. Never throws. */
|
|
288
|
+
function loadFleetState(stateFile) {
|
|
289
|
+
try {
|
|
290
|
+
const j = JSON.parse(external_node_fs_.readFileSync(stateFile, 'utf8'));
|
|
291
|
+
if (j && j.schema === SCHEMA) return j;
|
|
292
|
+
} catch { /* absent or unreadable -> start clean */ }
|
|
293
|
+
return { schema: SCHEMA, completed: {}, startedAt: null };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function _saveFleetState(stateFile, state) {
|
|
297
|
+
try {
|
|
298
|
+
external_node_fs_.mkdirSync(external_node_path_.dirname(stateFile), { recursive: true });
|
|
299
|
+
external_node_fs_.writeFileSync(stateFile, JSON.stringify(state, null, 2));
|
|
300
|
+
return true;
|
|
301
|
+
} catch { return false; }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Owners for a path, from a CODEOWNERS file.
|
|
306
|
+
*
|
|
307
|
+
* Last matching rule wins, which is the documented CODEOWNERS precedence — a
|
|
308
|
+
* first-match implementation silently routes to the most general owner, which
|
|
309
|
+
* is the one least able to act on the finding.
|
|
310
|
+
*/
|
|
311
|
+
function ownersFor(codeownersText, filePath) {
|
|
312
|
+
if (typeof codeownersText !== 'string' || !filePath) return [];
|
|
313
|
+
let owners = [];
|
|
314
|
+
for (const raw of codeownersText.split('\n')) {
|
|
315
|
+
const line = raw.replace(/#.*$/, '').trim();
|
|
316
|
+
if (!line) continue;
|
|
317
|
+
const parts = line.split(/\s+/);
|
|
318
|
+
const pattern = parts.shift();
|
|
319
|
+
const these = parts.filter(p => p.startsWith('@') || p.includes('@'));
|
|
320
|
+
if (!these.length) continue;
|
|
321
|
+
if (_matchesGlob(pattern, filePath)) owners = these;
|
|
322
|
+
}
|
|
323
|
+
return owners;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function _matchesGlob(pattern, file) {
|
|
327
|
+
const f = file.replace(/^\.?\//, '');
|
|
328
|
+
let p = pattern.replace(/^\.?\//, '');
|
|
329
|
+
if (p === '*') return true;
|
|
330
|
+
if (p.endsWith('/')) return f.startsWith(p);
|
|
331
|
+
const PLACEHOLDER = '\uE000'; // escape SEQUENCE in source -- 6 ASCII chars, not a raw embedded byte
|
|
332
|
+
const rx = new RegExp('^' + p
|
|
333
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
334
|
+
.replace(/\*\*/g, PLACEHOLDER)
|
|
335
|
+
.replace(/\*/g, '[^/]*')
|
|
336
|
+
.replace(new RegExp(PLACEHOLDER, 'g'), '.*') + '(/.*)?$');
|
|
337
|
+
return rx.test(f);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function _readCodeowners(repoRoot) {
|
|
341
|
+
for (const rel of ['CODEOWNERS', '.github/CODEOWNERS', 'docs/CODEOWNERS']) {
|
|
342
|
+
try { return external_node_fs_.readFileSync(external_node_path_.join(repoRoot, rel), 'utf8'); } catch { /* next */ }
|
|
343
|
+
}
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const SEVERITIES = ['critical', 'high', 'medium', 'low', 'info'];
|
|
348
|
+
|
|
349
|
+
function _summarise(scan) {
|
|
350
|
+
const all = [
|
|
351
|
+
...(scan.findings || []), ...(scan.secrets || []),
|
|
352
|
+
...(scan.logicVulns || []), ...(scan.supplyChain || []),
|
|
353
|
+
];
|
|
354
|
+
const bySeverity = Object.fromEntries(SEVERITIES.map(s => [s, 0]));
|
|
355
|
+
const ids = [];
|
|
356
|
+
let proven = 0;
|
|
357
|
+
for (const f of all) {
|
|
358
|
+
const sev = String(f.severity || 'info').toLowerCase();
|
|
359
|
+
if (sev in bySeverity) bySeverity[sev]++;
|
|
360
|
+
if (f.stableId) ids.push(f.stableId);
|
|
361
|
+
if (f.proofTier === 'execution-proven') proven++;
|
|
362
|
+
}
|
|
363
|
+
return { total: all.length, bySeverity, proven, ids, findings: all };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* M4 §4.4 — provenance-aware debt for ONE repo, computed here (inside the
|
|
368
|
+
* worker, from the untrimmed findings array) rather than inside `rollupFleet`,
|
|
369
|
+
* because FR-1005 forbids raw finding data from surviving onto the per-repo
|
|
370
|
+
* result entry (`test('FR-1005: ... the entry must not carry the raw findings
|
|
371
|
+
* array at all')` in fleet.test.js) — a finding can carry a raw source
|
|
372
|
+
* snippet, so only this derived summary (a finding id, a commit date, a day
|
|
373
|
+
* count; never a file path or snippet) is threaded onto `entry.provenanceDebt`.
|
|
374
|
+
*
|
|
375
|
+
* A finding contributes only when its `findingProvenance.status` is
|
|
376
|
+
* `'complete'` — the honest end of the confidence spectrum (see
|
|
377
|
+
* `provenance/schema.js`'s `PROVENANCE_STATUS`). `partial`/`uncommitted`/etc.
|
|
378
|
+
* are not proof of origin and must never win "oldest".
|
|
379
|
+
*/
|
|
380
|
+
function _provenanceDebtForRepo(findings, now = Date.now()) {
|
|
381
|
+
let oldest = null;
|
|
382
|
+
let completeCount = 0;
|
|
383
|
+
for (const f of (findings || [])) {
|
|
384
|
+
if (f.findingProvenance?.status !== 'complete') continue;
|
|
385
|
+
const authorDate = f.findingProvenance?.findingOrigin?.authorDate;
|
|
386
|
+
if (!authorDate) continue;
|
|
387
|
+
completeCount++;
|
|
388
|
+
const ageDays = Math.max(0, Math.floor((now - Date.parse(authorDate)) / 86400000));
|
|
389
|
+
if (!oldest || ageDays > oldest.ageDays) {
|
|
390
|
+
// FR-PROV-019: "Reports never show an age without its basis and
|
|
391
|
+
// confidence." The basis is structurally implied here (only
|
|
392
|
+
// status:'complete' findings reach this line, so the age is always
|
|
393
|
+
// git-derived, and the rendered text says "proven-origin") — but the
|
|
394
|
+
// confidence half has to be carried explicitly, same as
|
|
395
|
+
// mttr.js's renderSlaSummary. Captured at selection time so the
|
|
396
|
+
// renderers below cannot show the age without it.
|
|
397
|
+
oldest = {
|
|
398
|
+
findingId: f.id || f.stableId || null,
|
|
399
|
+
authorDate,
|
|
400
|
+
ageDays,
|
|
401
|
+
confidence: f.findingProvenance?.confidence?.level || null,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return { oldest, completeCount };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Scan many repositories with bounded concurrency, resumably.
|
|
410
|
+
*
|
|
411
|
+
* `runScan` is injected so this module has no engine import cycle and so the
|
|
412
|
+
* orchestration can be tested without running real scans.
|
|
413
|
+
*/
|
|
414
|
+
async function runFleet({
|
|
415
|
+
repos = [], concurrency = 4, stateFile, runScan,
|
|
416
|
+
resume = true, previous = null, onProgress = () => {},
|
|
417
|
+
// X-704: repositories whose every durable portfolio work unit is verified and current
|
|
418
|
+
// (portfolio/work-units.js `fullyVerifiedRepositories`). They are skipped like a resumed completed repo,
|
|
419
|
+
// and, as with a resumed repo, the skip is reported in `skipped` rather than counted as scanned.
|
|
420
|
+
portfolioVerified = [],
|
|
421
|
+
} = {}) {
|
|
422
|
+
if (typeof runScan !== 'function') {
|
|
423
|
+
return { ok: false, reason: 'no runScan supplied' };
|
|
424
|
+
}
|
|
425
|
+
const state = (resume && stateFile) ? loadFleetState(stateFile) : { schema: SCHEMA, completed: {}, startedAt: null };
|
|
426
|
+
const results = [];
|
|
427
|
+
const skipped = [];
|
|
428
|
+
|
|
429
|
+
const queue = repos.filter((r) => {
|
|
430
|
+
if ((resume && state.completed[r]) || portfolioVerified.includes(r)) { skipped.push(r); return false; }
|
|
431
|
+
return true;
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
const limit = Math.max(1, Math.min(concurrency, 32));
|
|
435
|
+
let cursor = 0;
|
|
436
|
+
|
|
437
|
+
async function worker() {
|
|
438
|
+
while (cursor < queue.length) {
|
|
439
|
+
const repo = queue[cursor++];
|
|
440
|
+
let entry;
|
|
441
|
+
try {
|
|
442
|
+
const { scan } = await runScan(repo);
|
|
443
|
+
const s = _summarise(scan);
|
|
444
|
+
const codeowners = _readCodeowners(repo);
|
|
445
|
+
// Route each finding to the owner of its file. Done here rather than at
|
|
446
|
+
// report time because the CODEOWNERS file lives in the repo, and by
|
|
447
|
+
// report time the fleet has moved on.
|
|
448
|
+
const owners = {};
|
|
449
|
+
if (codeowners) {
|
|
450
|
+
for (const f of s.findings) {
|
|
451
|
+
if (!f.file) continue;
|
|
452
|
+
const o = ownersFor(codeowners, f.file);
|
|
453
|
+
for (const own of o) (owners[own] ||= []).push(f.stableId || `${f.file}:${f.line}`);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
// FR-1006: "assurance-health views" and "policy drift" — a repo can
|
|
457
|
+
// be RISK-clean (zero/low findings) while still having a governance
|
|
458
|
+
// gap (a partial scan, a rejected/expired policy bundle nobody
|
|
459
|
+
// noticed). scanHealth comes straight from the engine's own
|
|
460
|
+
// computeScanHealth (FR-206) — nothing new is inferred here, this is
|
|
461
|
+
// the first time the fleet rollup SURFACES a signal that already
|
|
462
|
+
// existed per-scan. computePolicyDrift is null (not an empty object)
|
|
463
|
+
// when the repo has no policy bundles configured at all — matching
|
|
464
|
+
// this session's "no baseline configured -> no-op" convention.
|
|
465
|
+
let policyDrift = null;
|
|
466
|
+
try { policyDrift = (0,policy_bundle/* computePolicyDrift */.rE)(repo); } catch { /* best-effort — never fail a repo's scan over drift reporting */ }
|
|
467
|
+
// M4 §4.4: provenance-aware debt, computed HERE (from `s.findings`,
|
|
468
|
+
// the untrimmed array) rather than in `rollupFleet` — by the time
|
|
469
|
+
// `rollupFleet` sees `results`, the raw findings are gone (FR-1005).
|
|
470
|
+
// Only the derived summary (id/date/age, never a file or snippet)
|
|
471
|
+
// survives onto the entry. `mttr` threads through the SAME aggregate
|
|
472
|
+
// `mttr.js`'s `computeMTTR` already produces (`scan.mttr`, stamped by
|
|
473
|
+
// the CLI's persistence step — see posture/mttr.js) rather than
|
|
474
|
+
// inventing a second notion of remediation time; there is no
|
|
475
|
+
// `scan.remediatedFindings` field anywhere in this codebase.
|
|
476
|
+
const provenanceDebt = _provenanceDebtForRepo(s.findings);
|
|
477
|
+
entry = {
|
|
478
|
+
repo, ok: true, total: s.total, bySeverity: s.bySeverity,
|
|
479
|
+
proven: s.proven, ids: s.ids, owners, scannedAt: new Date().toISOString(),
|
|
480
|
+
scanHealth: scan.scanHealth || null, policyDrift,
|
|
481
|
+
provenanceDebt, mttr: scan.mttr || null,
|
|
482
|
+
};
|
|
483
|
+
} catch (e) {
|
|
484
|
+
// A failed repo is FAILED, never zero findings.
|
|
485
|
+
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 };
|
|
486
|
+
}
|
|
487
|
+
results.push(entry);
|
|
488
|
+
if (stateFile) {
|
|
489
|
+
// FR-1006 (assurance-hardening PRD): "scan freshness" — this field
|
|
490
|
+
// existed as an always-null stub before (never read anywhere, never
|
|
491
|
+
// populated), which is the same as not existing for anyone trying to
|
|
492
|
+
// answer "when was this repo last scanned". Stamped from the SAME
|
|
493
|
+
// entry.scannedAt the results array already carries, not a second,
|
|
494
|
+
// possibly-diverging timestamp.
|
|
495
|
+
state.completed[repo] = { at: entry.scannedAt, ok: entry.ok, total: entry.total };
|
|
496
|
+
_saveFleetState(stateFile, state);
|
|
497
|
+
}
|
|
498
|
+
onProgress(entry);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
await Promise.all(Array.from({ length: limit }, () => worker()));
|
|
503
|
+
return { ok: true, results, skipped, rollup: rollupFleet(results, previous) };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Roll many per-repo results into one view, with a delta when a previous run
|
|
508
|
+
* is supplied.
|
|
509
|
+
*/
|
|
510
|
+
function rollupFleet(results, previous = null) {
|
|
511
|
+
const scanned = results.filter(r => r.ok);
|
|
512
|
+
const failed = results.filter(r => !r.ok);
|
|
513
|
+
const bySeverity = Object.fromEntries(SEVERITIES.map(s => [s, 0]));
|
|
514
|
+
let total = 0, proven = 0;
|
|
515
|
+
for (const r of scanned) {
|
|
516
|
+
total += r.total || 0;
|
|
517
|
+
proven += r.proven || 0;
|
|
518
|
+
for (const s of SEVERITIES) bySeverity[s] += r.bySeverity?.[s] || 0;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Delta by stable id, not by count.
|
|
522
|
+
let newFindings = null;
|
|
523
|
+
if (previous && Array.isArray(previous.results)) {
|
|
524
|
+
const before = new Set(previous.results.flatMap(r => r.ids || []));
|
|
525
|
+
newFindings = {};
|
|
526
|
+
for (const r of scanned) {
|
|
527
|
+
const fresh = (r.ids || []).filter(id => !before.has(id));
|
|
528
|
+
if (fresh.length) newFindings[r.repo] = fresh;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// FR-1006: governance/coverage gaps, counted and surfaced SEPARATELY from
|
|
533
|
+
// the risk rollup above (total/bySeverity/proven) — a repo can contribute
|
|
534
|
+
// zero to those and still need attention here. `null` scanHealth (a
|
|
535
|
+
// failed repo, or a runScan that predates FR-206) is not counted as
|
|
536
|
+
// partial — absence of a signal is not evidence of a problem.
|
|
537
|
+
const partialScanHealth = scanned.filter(r => r.scanHealth && r.scanHealth.status !== 'complete');
|
|
538
|
+
const withPolicyDrift = scanned.filter(r => r.policyDrift && (r.policyDrift.overrides.length || r.policyDrift.rejected.length));
|
|
539
|
+
|
|
540
|
+
// M4 §4.4: provenance-aware debt + MTTR, surfaced as its own section
|
|
541
|
+
// alongside the FR-1006 governance section above — same additive pattern,
|
|
542
|
+
// same reason: a repo can be risk-clean and still carry old, PROVEN debt,
|
|
543
|
+
// and that must not be folded into (or hidden by) the risk rollup above.
|
|
544
|
+
//
|
|
545
|
+
// `oldestProvenDebt` only ever comes from a repo's own `provenanceDebt`,
|
|
546
|
+
// which `runFleet`'s worker computed from `findingProvenance.status ===
|
|
547
|
+
// 'complete'` findings only — never a wall-clock guess dressed as proof.
|
|
548
|
+
// A repo with no complete-status findings is disclosed BY NAME in
|
|
549
|
+
// `reposWithNoProvenDebt`, never silently absent from a "no debt" story a
|
|
550
|
+
// reader could misread as "this repo has no old findings" when the truth
|
|
551
|
+
// is "we couldn't PROVE any origin here". A `results` entry that predates
|
|
552
|
+
// this field (no `provenanceDebt` key at all — e.g. an older checkpoint,
|
|
553
|
+
// or a hand-built test fixture) is treated the same as "none found",
|
|
554
|
+
// consistent with every other optional signal in this rollup (scanHealth,
|
|
555
|
+
// policyDrift).
|
|
556
|
+
let oldestProvenDebt = null;
|
|
557
|
+
const reposWithNoProvenDebt = [];
|
|
558
|
+
const perRepoOldestProvenDebt = [];
|
|
559
|
+
for (const r of scanned) {
|
|
560
|
+
const oldest = r.provenanceDebt?.oldest || null;
|
|
561
|
+
if (!oldest) { reposWithNoProvenDebt.push(r.repo); continue; }
|
|
562
|
+
const withRepo = { repo: r.repo, ...oldest };
|
|
563
|
+
perRepoOldestProvenDebt.push(withRepo);
|
|
564
|
+
if (!oldestProvenDebt || oldest.ageDays > oldestProvenDebt.ageDays) oldestProvenDebt = withRepo;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Fleet-wide MTTR reuses each repo's own `scan.mttr` (mttr.js's
|
|
568
|
+
// `computeMTTR` output — `{count, meanDays, medianDays, perSeverity}`),
|
|
569
|
+
// combined as a count-weighted mean, rather than recomputing wall-clock
|
|
570
|
+
// remediation time here from a second source of truth. `byAgeBasis` is
|
|
571
|
+
// deliberately always `{}`: `computeMTTR` does not track ageBasis per
|
|
572
|
+
// remediated finding (only per-severity), and there is no
|
|
573
|
+
// `scan.remediatedFindings` array anywhere in this codebase to derive one
|
|
574
|
+
// from — see `runFleet`'s worker comment. Reporting a fabricated breakdown
|
|
575
|
+
// would be worse than reporting none.
|
|
576
|
+
//
|
|
577
|
+
// FINAL-REVIEW FIX (M4, item 4): `mttr` used to default to `{n:0, ...}`
|
|
578
|
+
// whenever no scanned repo's `.mttr` counted a remediation — which is
|
|
579
|
+
// exactly what happens on EVERY real production fleet run, since
|
|
580
|
+
// `scripts/fleet.mjs` drives `runFleet` with `runScan` straight from
|
|
581
|
+
// `src/runScan.js`, and that function never sets `.mttr` at all (the only
|
|
582
|
+
// real setter is the CLI's OWN single-repo persistence step in
|
|
583
|
+
// `bin/agentic-security.js`, which fleet mode does not go through — see
|
|
584
|
+
// that file's comment for the full investigation). `{n:0,...}` and "no
|
|
585
|
+
// repo ever attempted to compute this" are materially different facts:
|
|
586
|
+
// the first is a real, honest zero; the second is a missing capability.
|
|
587
|
+
// Collapsing them made every real fleet run silently render a fabricated-
|
|
588
|
+
// looking "no remediated findings recorded yet" line that reads as a
|
|
589
|
+
// measurement rather than an absence.
|
|
590
|
+
//
|
|
591
|
+
// `anyMttrSupplied` distinguishes them: it is true only when at least one
|
|
592
|
+
// SCANNED repo's entry carried a non-null `.mttr` object at all — which
|
|
593
|
+
// only happens when something upstream (the CLI's own persistence path,
|
|
594
|
+
// or a caller-supplied `runScan` that wires the same pipeline itself)
|
|
595
|
+
// actually ran `computeMTTR`. `mttr` stays `null` — not a zeroed object —
|
|
596
|
+
// when nothing did, so a consumer can tell "tracked, zero fixes so far"
|
|
597
|
+
// from "not tracked at all" without inspecting `n`.
|
|
598
|
+
let mttrCount = 0, mttrWeightedDays = 0, anyMttrSupplied = false;
|
|
599
|
+
for (const r of scanned) {
|
|
600
|
+
const m = r.mttr;
|
|
601
|
+
if (m == null) continue;
|
|
602
|
+
anyMttrSupplied = true;
|
|
603
|
+
if (typeof m.count === 'number' && m.count > 0 && typeof m.meanDays === 'number') {
|
|
604
|
+
mttrCount += m.count;
|
|
605
|
+
mttrWeightedDays += m.meanDays * m.count;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
const mttr = anyMttrSupplied
|
|
609
|
+
? { n: mttrCount, meanDays: mttrCount ? mttrWeightedDays / mttrCount : null, byAgeBasis: {} }
|
|
610
|
+
: null;
|
|
611
|
+
|
|
612
|
+
return {
|
|
613
|
+
repos: results.length,
|
|
614
|
+
scanned: scanned.length,
|
|
615
|
+
// Surfaced at the top level, not buried: a fleet report whose failures are
|
|
616
|
+
// a footnote reads as a clean fleet.
|
|
617
|
+
failed: failed.length,
|
|
618
|
+
failures: failed.map(f => ({ repo: f.repo, error: f.error })),
|
|
619
|
+
total, proven, bySeverity,
|
|
620
|
+
reposWithPartialScanHealth: partialScanHealth.length,
|
|
621
|
+
reposWithPolicyDrift: withPolicyDrift.length,
|
|
622
|
+
...(newFindings ? { newFindings } : {}),
|
|
623
|
+
provenance: { oldestProvenDebt, reposWithNoProvenDebt, perRepoOldestProvenDebt, mttr },
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** One-line summary. Leads with failures when there are any. */
|
|
628
|
+
function renderFleetSummary(rollup) {
|
|
629
|
+
if (!rollup) return null;
|
|
630
|
+
const head = rollup.failed
|
|
631
|
+
? `${rollup.failed} of ${rollup.repos} repo(s) FAILED to scan — their findings are unknown, not zero; `
|
|
632
|
+
: '';
|
|
633
|
+
const sev = SEVERITIES.filter(s => rollup.bySeverity[s]).map(s => `${rollup.bySeverity[s]} ${s}`).join(', ');
|
|
634
|
+
const nf = rollup.newFindings
|
|
635
|
+
? ` ${Object.values(rollup.newFindings).reduce((a, b) => a + b.length, 0)} new since last run.`
|
|
636
|
+
: '';
|
|
637
|
+
// FR-1006: governance/coverage gaps reported as their own clause, never
|
|
638
|
+
// folded into the risk sentence — a fleet with zero findings but 12 repos
|
|
639
|
+
// on a partial scan is not "clean."
|
|
640
|
+
const gov = [];
|
|
641
|
+
if (rollup.reposWithPartialScanHealth) gov.push(`${rollup.reposWithPartialScanHealth} repo(s) with a partial (not complete) scan`);
|
|
642
|
+
if (rollup.reposWithPolicyDrift) gov.push(`${rollup.reposWithPolicyDrift} repo(s) with policy drift or a rejected policy bundle`);
|
|
643
|
+
const govClause = gov.length ? ` GOVERNANCE: ${gov.join('; ')}.` : '';
|
|
644
|
+
// M4 §4.4: proven-origin debt as its own clause, matching the governance
|
|
645
|
+
// clause's honesty rule — a null `oldestProvenDebt` gets an explicit "none"
|
|
646
|
+
// line rather than silently vanishing from the sentence, which would read
|
|
647
|
+
// as "no debt" instead of "nothing PROVEN yet."
|
|
648
|
+
const prov = rollup.provenance;
|
|
649
|
+
let provClause = '';
|
|
650
|
+
if (prov) {
|
|
651
|
+
// FR-PROV-019: the age never ships without its basis ("proven-origin",
|
|
652
|
+
// structurally guaranteed by the status:'complete' filter upstream) AND
|
|
653
|
+
// its confidence. A finding whose provenance carried no confidence level
|
|
654
|
+
// says so rather than implying certainty by omission.
|
|
655
|
+
const bits = prov.oldestProvenDebt
|
|
656
|
+
? [`oldest proven-origin finding: ${prov.oldestProvenDebt.repo} (${prov.oldestProvenDebt.ageDays}d, `
|
|
657
|
+
+ `${prov.oldestProvenDebt.confidence ? `${String(prov.oldestProvenDebt.confidence).toUpperCase()} confidence` : 'confidence unknown'})`]
|
|
658
|
+
: ['no proven-origin findings across the fleet'];
|
|
659
|
+
if (prov.reposWithNoProvenDebt.length) bits.push(`${prov.reposWithNoProvenDebt.length} repo(s) with no complete-status provenance`);
|
|
660
|
+
provClause = ` PROVENANCE: ${bits.join('; ')}.`;
|
|
661
|
+
}
|
|
662
|
+
// X-707: portfolio progress is its own clause, present only when the `portfolio-assurance` feature attached it
|
|
663
|
+
// (portfolio/progress.js `attachPortfolioProgress`), so flag-off output is byte-identical. It states verified units and
|
|
664
|
+
// repositories separately from the scan counts above: a finished controller is not a passing portfolio.
|
|
665
|
+
const pp = rollup.portfolioProgress;
|
|
666
|
+
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}` : '';
|
|
667
|
+
return `${head}${rollup.scanned} repo(s) scanned, ${rollup.total} finding(s)`
|
|
668
|
+
+ `${sev ? ` (${sev})` : ''}, ${rollup.proven} execution-proven.${nf}${govClause}${provClause}${portfolioClause}`;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* A self-contained rollup page. No network, no scripts, no fonts — it must
|
|
673
|
+
* render from local artifacts on an air-gapped machine (PRD AC3).
|
|
674
|
+
*/
|
|
675
|
+
function renderFleetHtml(rollup, results) {
|
|
676
|
+
const esc = (s) => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
677
|
+
// FR-1006: "scan freshness" — WHEN each repo was last scanned, not just
|
|
678
|
+
// whether it was. A failed repo still carries a real scannedAt (the
|
|
679
|
+
// attempt happened; it just didn't succeed) so a reader can tell "failed
|
|
680
|
+
// just now" from "failed, and nobody has retried in weeks" apart.
|
|
681
|
+
const rows = results.map(r => r.ok
|
|
682
|
+
? `<tr><td>${esc(r.repo)}</td><td>${r.total}</td>`
|
|
683
|
+
+ SEVERITIES.map(s => `<td>${r.bySeverity?.[s] ?? 0}</td>`).join('')
|
|
684
|
+
+ `<td>${r.proven ?? 0}</td><td>${esc(r.scannedAt || 'unknown')}</td></tr>`
|
|
685
|
+
: `<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');
|
|
686
|
+
// FR-1006: a SEPARATE table, not a column bolted onto the risk table above
|
|
687
|
+
// — "distinguishes risk findings from scan and governance coverage gaps"
|
|
688
|
+
// is most honestly satisfied by keeping the two visually and structurally
|
|
689
|
+
// apart, not by merging them into one row a reader has to parse. Only
|
|
690
|
+
// repos with something to report appear here; a fleet with nothing to flag
|
|
691
|
+
// gets a one-line "nothing to report" rather than an empty table.
|
|
692
|
+
const govRows = results
|
|
693
|
+
.filter(r => (r.scanHealth && r.scanHealth.status !== 'complete') || (r.policyDrift && (r.policyDrift.overrides.length || r.policyDrift.rejected.length)))
|
|
694
|
+
.map(r => {
|
|
695
|
+
const healthCell = r.scanHealth && r.scanHealth.status !== 'complete'
|
|
696
|
+
? esc(`${r.scanHealth.status}: ${(r.scanHealth.conditions || []).join('; ') || 'see scanHealth'}`)
|
|
697
|
+
: '—';
|
|
698
|
+
const driftParts = [];
|
|
699
|
+
if (r.policyDrift?.overrides?.length) {
|
|
700
|
+
driftParts.push(...r.policyDrift.overrides.map(o => `${esc(o.key)} overridden by ${esc(o.overriddenBy)}`));
|
|
701
|
+
}
|
|
702
|
+
if (r.policyDrift?.rejected?.length) {
|
|
703
|
+
driftParts.push(...r.policyDrift.rejected.map(x => `${esc(x.scope)} policy REJECTED: ${esc(x.reason)}`));
|
|
704
|
+
}
|
|
705
|
+
const driftCell = driftParts.length ? driftParts.join('; ') : '—';
|
|
706
|
+
return `<tr><td>${esc(r.repo)}</td><td>${healthCell}</td><td>${driftCell}</td></tr>`;
|
|
707
|
+
}).join('\n');
|
|
708
|
+
const govSection = govRows
|
|
709
|
+
? `<h2>Governance / coverage gaps</h2>
|
|
710
|
+
<table border="1" cellpadding="4">
|
|
711
|
+
<tr><th>repo</th><th>scan health</th><th>policy drift</th></tr>
|
|
712
|
+
${govRows}
|
|
713
|
+
</table>`
|
|
714
|
+
: `<h2>Governance / coverage gaps</h2>
|
|
715
|
+
<p>Nothing to report — every scanned repo has a complete scan and no policy drift.</p>`;
|
|
716
|
+
// M4 §4.4: a SEPARATE section, same precedent as the governance section
|
|
717
|
+
// above — proven-origin debt is neither a risk-severity fact nor a
|
|
718
|
+
// governance-coverage fact, so it gets its own visual and structural space
|
|
719
|
+
// rather than being bolted onto either existing table. Lists the oldest
|
|
720
|
+
// finding PER REPO (not just the single fleet-wide oldest), so a reader can
|
|
721
|
+
// see which repos are carrying old proven debt, not only the single worst
|
|
722
|
+
// one.
|
|
723
|
+
const prov = rollup.provenance;
|
|
724
|
+
let provSection = '';
|
|
725
|
+
if (prov) {
|
|
726
|
+
// FR-PROV-019: the confidence column is not decoration — an age column
|
|
727
|
+
// with no confidence beside it is exactly the "age without its basis and
|
|
728
|
+
// confidence" the PRD forbids. The basis is the table's own framing
|
|
729
|
+
// (every row is proven-origin by construction); the confidence has to be
|
|
730
|
+
// shown per row, and an absent level reads "unknown" rather than blank,
|
|
731
|
+
// so a missing value can never be mistaken for a confident one.
|
|
732
|
+
const provRows = (prov.perRepoOldestProvenDebt || [])
|
|
733
|
+
.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>`)
|
|
734
|
+
.join('\n');
|
|
735
|
+
const provTable = provRows
|
|
736
|
+
? `<table border="1" cellpadding="4">
|
|
737
|
+
<tr><th>repo</th><th>finding</th><th>author date</th><th>age (days)</th><th>origin confidence</th></tr>
|
|
738
|
+
${provRows}
|
|
739
|
+
</table>`
|
|
740
|
+
: `<p>No complete-status (proven-origin) findings across the fleet.</p>`;
|
|
741
|
+
const noProvenNote = prov.reposWithNoProvenDebt.length
|
|
742
|
+
? `<p>${prov.reposWithNoProvenDebt.length} repo(s) with no complete-status provenance: ${prov.reposWithNoProvenDebt.map(esc).join(', ')}</p>`
|
|
743
|
+
: '';
|
|
744
|
+
const mttrByBasisRows = Object.entries(prov.mttr?.byAgeBasis || {})
|
|
745
|
+
.map(([basis, n]) => `<tr><td>${esc(basis)}</td><td>${n}</td></tr>`).join('\n');
|
|
746
|
+
const mttrByBasisTable = mttrByBasisRows
|
|
747
|
+
? `<table border="1" cellpadding="4">
|
|
748
|
+
<tr><th>age basis</th><th>count</th></tr>
|
|
749
|
+
${mttrByBasisRows}
|
|
750
|
+
</table>`
|
|
751
|
+
: '';
|
|
752
|
+
// Three distinct states, not two (final-review fix, item 4): `prov.mttr
|
|
753
|
+
// === null` means no scanned repo ever supplied a real mttr.js aggregate
|
|
754
|
+
// at all — the honest disclosure is "not available", never "no
|
|
755
|
+
// remediated findings recorded yet", which reads as a real zero
|
|
756
|
+
// measurement rather than an absent capability. A non-null `mttr` with
|
|
757
|
+
// `n === 0` is the legitimate zero: something DID track remediation and
|
|
758
|
+
// genuinely found none yet.
|
|
759
|
+
const mttrLine = !prov.mttr
|
|
760
|
+
? `<p>Fleet MTTR: not available — fleet mode does not yet track finding remediation history across runs.</p>`
|
|
761
|
+
: prov.mttr.n > 0
|
|
762
|
+
? `<p>Fleet MTTR: ${prov.mttr.n} remediated finding(s)${prov.mttr.meanDays != null ? `, mean ${Math.round(prov.mttr.meanDays)}d to remediate` : ''}.</p>${mttrByBasisTable}`
|
|
763
|
+
: `<p>Fleet MTTR: no remediated findings recorded yet.</p>`;
|
|
764
|
+
provSection = `<h2>Provenance-Proven Debt</h2>
|
|
765
|
+
${provTable}
|
|
766
|
+
${noProvenNote}
|
|
767
|
+
${mttrLine}`;
|
|
768
|
+
}
|
|
769
|
+
return `<!-- generated offline; no external resources -->
|
|
770
|
+
<h1>Fleet scan</h1>
|
|
771
|
+
<p>${esc(renderFleetSummary(rollup) || '')}</p>
|
|
772
|
+
<h2>Risk findings</h2>
|
|
773
|
+
<table border="1" cellpadding="4">
|
|
774
|
+
<tr><th>repo</th><th>total</th>${SEVERITIES.map(s => `<th>${s}</th>`).join('')}<th>proven</th><th>scanned at</th></tr>
|
|
775
|
+
${rows}
|
|
776
|
+
</table>
|
|
777
|
+
${govSection}
|
|
778
|
+
${provSection}`;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const _internals = { SCHEMA, SEVERITIES, _matchesGlob, _summarise, _provenanceDebtForRepo };
|
|
782
|
+
|
|
783
|
+
// EXTERNAL MODULE: ./src/posture/state-dir.js
|
|
784
|
+
var state_dir = __webpack_require__(69259);
|
|
785
|
+
;// CONCATENATED MODULE: ./src/posture/fleet/cli.js
|
|
786
|
+
// `agentic-security fleet init|scan|status|resume|query`.
|
|
787
|
+
//
|
|
788
|
+
// Reuses the existing posture/fleet.js (runFleet: bounded concurrency, resumable checkpoint, per-repo isolation,
|
|
789
|
+
// honest rollup) for the scanning half — this module adds only the manifest-driven entry point and the
|
|
790
|
+
// cross-repository dependency graph (fleet-graph.js) that runFleet has no notion of.
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
const GRAPH_FILE = 'fleet-graph.json';
|
|
799
|
+
const STATE_FILE = 'fleet-state.json';
|
|
800
|
+
|
|
801
|
+
function graphPath(root) { return (0,state_dir.statePath)(root, GRAPH_FILE); }
|
|
802
|
+
function statePathFor(root) { return (0,state_dir.statePath)(root, STATE_FILE); }
|
|
803
|
+
|
|
804
|
+
async function cmdFleet(args, root) {
|
|
805
|
+
const sub = args._[1];
|
|
806
|
+
if (sub === 'init') return cmdInit(args, root);
|
|
807
|
+
if (sub === 'scan') return cmdScan(args, root);
|
|
808
|
+
if (sub === 'status') return cmdStatus(args, root);
|
|
809
|
+
if (sub === 'resume') return cmdScan(args, root, { resumeOnly: true });
|
|
810
|
+
if (sub === 'query') return cmdQuery(args, root);
|
|
811
|
+
console.error('usage: fleet init | scan <manifest.yaml> | status <manifest.yaml> | resume <manifest.yaml> | query --depends-on <repo-id> [<manifest.yaml>]');
|
|
812
|
+
return 4;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function manifestArg(args) {
|
|
816
|
+
const m = args._[2];
|
|
817
|
+
return m && !String(m).startsWith('--') ? m : 'fleet.yaml';
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
async function cmdInit(args, root) {
|
|
821
|
+
const target = external_node_path_.resolve(root, manifestArg(args) === 'fleet.yaml' ? 'fleet.yaml' : manifestArg(args));
|
|
822
|
+
if (external_node_fs_.existsSync(target) && !args.flags.force) { console.error(`${target} already exists. Pass --force to overwrite.`); return 4; }
|
|
823
|
+
external_node_fs_.writeFileSync(target, starterManifestYaml(args.flags.organization || 'example'));
|
|
824
|
+
console.log(`Wrote ${target}. Edit it to list your repositories, then run: agentic-security fleet scan ${external_node_path_.basename(target)}`);
|
|
825
|
+
return 0;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
async function cmdScan(args, root, { resumeOnly = false } = {}) {
|
|
829
|
+
const manifestFile = external_node_path_.resolve(root, manifestArg(args));
|
|
830
|
+
const loaded = loadFleetManifest(manifestFile);
|
|
831
|
+
if (!loaded.ok) { console.error(`Invalid fleet manifest: ${loaded.errors.join('; ')}`); return 4; }
|
|
832
|
+
const { manifest } = loaded;
|
|
833
|
+
|
|
834
|
+
if (!(0,state_dir/* ensureStateDir */.xH)(root)) { console.error('No safe state directory for this project.'); return 4; }
|
|
835
|
+
const prevRollupFile = (0,state_dir.statePath)(root, 'fleet-rollup.json');
|
|
836
|
+
let previous = null;
|
|
837
|
+
try { previous = JSON.parse(external_node_fs_.readFileSync(prevRollupFile, 'utf8')); } catch { /* first run */ }
|
|
838
|
+
|
|
839
|
+
const { runScan: runScanFn } = await Promise.resolve(/* import() */).then(__webpack_require__.bind(__webpack_require__, 58307));
|
|
840
|
+
// runScanFn resolves to { scan, meta } already — runFleet's worker expects exactly that shape from runScan, so
|
|
841
|
+
// this must NOT wrap `scan` a second time (an earlier draft did, and every repo silently reported 0 findings:
|
|
842
|
+
// `_summarise` reads `scan.findings` defensively rather than throwing on the wrong shape).
|
|
843
|
+
const runScan = (repoPath) => runScanFn(repoPath, { provenance: false });
|
|
844
|
+
|
|
845
|
+
console.error(`[fleet] scanning ${manifest.repositories.length} repository(ies) for ${manifest.organization} (${manifest.workers} worker(s))...`);
|
|
846
|
+
const result = await runFleet({
|
|
847
|
+
repos: manifest.repositories.map((r) => r.id), concurrency: manifest.workers,
|
|
848
|
+
stateFile: statePathFor(root), resume: true, previous,
|
|
849
|
+
runScan: async (repoId) => runScan(manifest.repositories.find((r) => r.id === repoId).path),
|
|
850
|
+
onProgress: (entry) => { console.error(`[fleet] ${entry.ok ? 'done' : 'FAILED'}: ${entry.repo}${entry.ok ? ` (${entry.total} finding(s))` : ` — ${entry.error}`}`); },
|
|
851
|
+
});
|
|
852
|
+
if (resumeOnly && result.skipped.length === manifest.repositories.length) console.error('[fleet] nothing to resume — every repository was already complete.');
|
|
853
|
+
|
|
854
|
+
(0,state_dir.safeWriteState)(prevRollupFile, JSON.stringify(result.rollup, null, 2));
|
|
855
|
+
const graph = buildFleetDependencyGraph(manifest.repositories);
|
|
856
|
+
(0,state_dir.safeWriteState)(graphPath(root), JSON.stringify({ organization: manifest.organization, builtAt: new Date().toISOString(), ...graph }, null, 2));
|
|
857
|
+
|
|
858
|
+
console.log(renderFleetSummary(result.rollup));
|
|
859
|
+
console.log(`\nRepositories requested: ${manifest.repositories.length}`);
|
|
860
|
+
console.log(`Complete: ${result.results.filter((r) => r.ok).length + result.skipped.length}`);
|
|
861
|
+
console.log(`Failed: ${result.results.filter((r) => !r.ok).length}`);
|
|
862
|
+
const anyFailed = result.results.some((r) => !r.ok);
|
|
863
|
+
return anyFailed ? 1 : 0;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
async function cmdStatus(args, root) {
|
|
867
|
+
const f = (0,state_dir.statePath)(root, 'fleet-rollup.json');
|
|
868
|
+
if (!external_node_fs_.existsSync(f)) { console.error('No fleet scan has been run yet. Run `agentic-security fleet scan <manifest.yaml>` first.'); return 4; }
|
|
869
|
+
const rollup = JSON.parse(external_node_fs_.readFileSync(f, 'utf8'));
|
|
870
|
+
if (args.flags.json) console.log(JSON.stringify(rollup, null, 2));
|
|
871
|
+
else console.log(renderFleetSummary(rollup));
|
|
872
|
+
return 0;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
async function cmdQuery(args, root) {
|
|
876
|
+
const target = args.flags['depends-on'];
|
|
877
|
+
if (!target) { console.error('usage: fleet query --depends-on <repo-id>'); return 4; }
|
|
878
|
+
const f = graphPath(root);
|
|
879
|
+
if (!external_node_fs_.existsSync(f)) { console.error('No fleet dependency graph yet. Run `agentic-security fleet scan <manifest.yaml>` first.'); return 4; }
|
|
880
|
+
const graph = JSON.parse(external_node_fs_.readFileSync(f, 'utf8'));
|
|
881
|
+
if (!graph.nodes.some((n) => n.id === target)) { console.error(`${target} is not a repository in the last fleet scan.`); return 4; }
|
|
882
|
+
const radius = blastRadius(graph, target);
|
|
883
|
+
if (args.flags.json) { console.log(JSON.stringify({ target, blastRadius: radius }, null, 2)); return 0; }
|
|
884
|
+
if (!radius.length) { console.log(`No fleet repository depends on ${target} (by declared package name).`); return 0; }
|
|
885
|
+
console.log(`Repositories depending on ${target} (directly or transitively):`);
|
|
886
|
+
for (const r of radius) console.log(` ${r.id} (depth ${r.depth}, via ${r.path.slice(1).join(' -> ')})`);
|
|
887
|
+
return 0;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
/***/ }),
|
|
892
|
+
|
|
893
|
+
/***/ 14413:
|
|
894
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
895
|
+
|
|
896
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
897
|
+
/* harmony export */ buildPolicyBundle: () => (/* binding */ buildPolicyBundle),
|
|
898
|
+
/* harmony export */ ensurePolicyKeyPair: () => (/* binding */ ensurePolicyKeyPair),
|
|
899
|
+
/* harmony export */ loadPolicyBundles: () => (/* binding */ loadPolicyBundles),
|
|
900
|
+
/* harmony export */ loadPolicyPublicKey: () => (/* binding */ loadPolicyPublicKey),
|
|
901
|
+
/* harmony export */ rE: () => (/* binding */ computePolicyDrift),
|
|
902
|
+
/* harmony export */ resolveEffectivePolicy: () => (/* binding */ resolveEffectivePolicy),
|
|
903
|
+
/* harmony export */ signPolicyBundle: () => (/* binding */ signPolicyBundle)
|
|
904
|
+
/* harmony export */ });
|
|
905
|
+
/* unused harmony exports POLICY_BUNDLE_SCHEMA, SCOPES, canonicalPolicyBytes, verifyPolicyBundle */
|
|
906
|
+
/* harmony import */ var node_fs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(73024);
|
|
907
|
+
/* harmony import */ var node_os__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(48161);
|
|
908
|
+
/* harmony import */ var node_path__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(76760);
|
|
909
|
+
/* harmony import */ var node_crypto__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(77598);
|
|
910
|
+
/* harmony import */ var _evidence_bundle_js__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(32876);
|
|
911
|
+
/* harmony import */ var _state_dir_js__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(69259);
|
|
912
|
+
// Signed, portable policy bundles (assurance-hardening PRD FR-1001).
|
|
913
|
+
//
|
|
914
|
+
// "Support signed portable policy bundles with organization, repository, and
|
|
915
|
+
// environment inheritance" | "Tampered or expired policy is rejected;
|
|
916
|
+
// effective policy is explainable."
|
|
917
|
+
//
|
|
918
|
+
// WHY REUSE evidence-bundle.js's Ed25519 SCHEME, NOT integrity.js's HMAC
|
|
919
|
+
// -------------------------------------------------------------------------
|
|
920
|
+
// A policy bundle is authored by one party (typically a central security
|
|
921
|
+
// team, "the organization") and distributed to many repositories that never
|
|
922
|
+
// had the signing key. integrity.js's per-install symmetric HMAC is
|
|
923
|
+
// tamper-evidence for the SAME install that wrote it — a different install
|
|
924
|
+
// verifying a bundle it didn't sign is exactly the case that scheme cannot
|
|
925
|
+
// serve. evidence-bundle.js already solved this for findings: Ed25519,
|
|
926
|
+
// verify with only the public key. This module reuses that primitive
|
|
927
|
+
// directly (canonicalisation shape, allowlist-signed-fields discipline,
|
|
928
|
+
// exclusive-create key generation) rather than inventing a third signing
|
|
929
|
+
// mechanism, per this session's own established "survey before building a
|
|
930
|
+
// parallel primitive" practice (see D-0023/D-0025 for the same discipline
|
|
931
|
+
// applied to identity and exception mechanisms respectively).
|
|
932
|
+
//
|
|
933
|
+
// EA-03 LESSON, APPLIED HERE FROM THE START
|
|
934
|
+
// -------------------------------------------------------------------------
|
|
935
|
+
// evidence-bundle.js's own history (see its EA-03 comment) is the reason
|
|
936
|
+
// `canonicalPolicyBytes` signs an explicit ALLOWLIST and `verifyPolicyBundle`
|
|
937
|
+
// REJECTS any top-level key outside it: a bundle with a field stapled on
|
|
938
|
+
// after signing must fail verification, not silently pass with the addition
|
|
939
|
+
// unverified. Getting this right on the first pass — rather than discovering
|
|
940
|
+
// the gap the way evidence-bundle.js did — is the entire point of writing it
|
|
941
|
+
// down here.
|
|
942
|
+
//
|
|
943
|
+
// SCOPE — WHAT THIS MODULE DOES NOT DO
|
|
944
|
+
// -------------------------------------------------------------------------
|
|
945
|
+
// It does not invent a policy SCHEMA — `policy` is an opaque object; whatever
|
|
946
|
+
// keys a bundle carries (egress rules, severity floors, approved providers,
|
|
947
|
+
// anything else this repo already treats as policy) are merged the same way.
|
|
948
|
+
// It does not implement deep/recursive merging — inheritance is shallow,
|
|
949
|
+
// top-level-key override (organization -> repository -> environment, most
|
|
950
|
+
// specific wins per key), matching how every other flat policy config file
|
|
951
|
+
// in this repo (egress-policy.yml, compliance-severity-policy.json, …) is
|
|
952
|
+
// already shaped. A future consumer needing nested-key inheritance is new,
|
|
953
|
+
// separate scope, not assumed here.
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
const POLICY_BUNDLE_SCHEMA = 'agentic-security/policy-bundle@1';
|
|
963
|
+
|
|
964
|
+
const SCOPES = ['organization', 'repository', 'environment'];
|
|
965
|
+
// Inheritance order — later entries override earlier ones, per top-level key.
|
|
966
|
+
const INHERITANCE_ORDER = ['organization', 'repository', 'environment'];
|
|
967
|
+
|
|
968
|
+
function keyDir() {
|
|
969
|
+
const xdg = process.env.XDG_CONFIG_HOME || node_path__WEBPACK_IMPORTED_MODULE_2__.join(node_os__WEBPACK_IMPORTED_MODULE_1__.homedir(), '.config');
|
|
970
|
+
return node_path__WEBPACK_IMPORTED_MODULE_2__.join(xdg, 'agentic-security', 'policy-bundles');
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/** A SEPARATE Ed25519 keypair from evidence-bundle.js's finding-attestation
|
|
974
|
+
* key — signing "this policy is authentic" and "this finding really came
|
|
975
|
+
* from a scan run by me" are different trust domains and must not share a
|
|
976
|
+
* key. Reuses evidence-bundle.js's hardened, race-safe generation logic
|
|
977
|
+
* directly (its exclusive-create collision handling is the kind of code
|
|
978
|
+
* worth NOT re-implementing) rather than duplicating it — the separation
|
|
979
|
+
* that matters is the DIRECTORY (`policy-bundles/`, isolated from
|
|
980
|
+
* evidence-bundle.js's own key dir), not the leaf filenames, which are
|
|
981
|
+
* `attest-key.pem`/`attest-key.pub.pem` regardless of caller (a
|
|
982
|
+
* evidence-bundle.js internal, not overridable here) — a readability wart,
|
|
983
|
+
* not a security one, since the directory is what prevents key reuse. */
|
|
984
|
+
function ensurePolicyKeyPair(dir = keyDir()) {
|
|
985
|
+
return (0,_evidence_bundle_js__WEBPACK_IMPORTED_MODULE_4__.ensureKeyPair)(dir);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* Deterministic JSON, keys sorted at every level — identical algorithm to
|
|
990
|
+
* evidence-bundle.js's canonicalJson (duplicated rather than imported: it is
|
|
991
|
+
* a pure, three-line function, and importing it would couple this module's
|
|
992
|
+
* signature format to evidence-bundle.js's internals for no real benefit).
|
|
993
|
+
*/
|
|
994
|
+
function canonicalJson(value) {
|
|
995
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value ?? null);
|
|
996
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
|
997
|
+
const keys = Object.keys(value).sort();
|
|
998
|
+
return `{${keys.map(k => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(',')}}`;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// The complete set of fields a legitimately-built bundle carries, BEFORE
|
|
1002
|
+
// signing. This is the signed allowlist AND (in verifyPolicyBundle) the
|
|
1003
|
+
// complete set of keys a signed bundle is permitted to have alongside
|
|
1004
|
+
// `signature` — see the EA-03 note above for why both matter.
|
|
1005
|
+
const BUNDLE_FIELDS = ['schema', 'scope', 'policy', 'issuedAt', 'expiresAt'];
|
|
1006
|
+
const BUNDLE_TOP_LEVEL_KEYS = new Set([...BUNDLE_FIELDS, 'signature']);
|
|
1007
|
+
|
|
1008
|
+
function canonicalPolicyBytes(bundle) {
|
|
1009
|
+
const signed = {};
|
|
1010
|
+
for (const k of BUNDLE_FIELDS) signed[k] = bundle[k] ?? null;
|
|
1011
|
+
return Buffer.from(canonicalJson(signed), 'utf8');
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* Build an unsigned bundle. `policy` is caller-supplied and opaque — this
|
|
1016
|
+
* module does not validate its shape, only its provenance and freshness.
|
|
1017
|
+
*/
|
|
1018
|
+
function buildPolicyBundle(scope, policy, { issuedAt, expiresAt } = {}) {
|
|
1019
|
+
if (!SCOPES.includes(scope)) return null;
|
|
1020
|
+
if (!policy || typeof policy !== 'object' || Array.isArray(policy)) return null;
|
|
1021
|
+
return {
|
|
1022
|
+
schema: POLICY_BUNDLE_SCHEMA,
|
|
1023
|
+
scope,
|
|
1024
|
+
policy,
|
|
1025
|
+
issuedAt: issuedAt ?? new Date().toISOString(),
|
|
1026
|
+
expiresAt: expiresAt ?? null,
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/** Sign a bundle. Returns a new object; the input is not mutated. */
|
|
1031
|
+
function signPolicyBundle(bundle, privateKeyPem) {
|
|
1032
|
+
const sig = node_crypto__WEBPACK_IMPORTED_MODULE_3__.sign(null, canonicalPolicyBytes(bundle), privateKeyPem);
|
|
1033
|
+
return {
|
|
1034
|
+
...bundle,
|
|
1035
|
+
signature: { algorithm: 'ed25519', canonicalisation: POLICY_BUNDLE_SCHEMA, value: sig.toString('base64') },
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/**
|
|
1040
|
+
* Verify a bundle: signature authenticity AND freshness. Never throws — a
|
|
1041
|
+
* malformed or tampered bundle from an untrusted source (a repo checking out
|
|
1042
|
+
* whatever an org distributed) is an expected input, not exceptional.
|
|
1043
|
+
*
|
|
1044
|
+
* @returns {{ok: boolean, reason: string|null}}
|
|
1045
|
+
*/
|
|
1046
|
+
function verifyPolicyBundle(bundle, publicKeyPem, { now = Date.now() } = {}) {
|
|
1047
|
+
if (!bundle || typeof bundle !== 'object') return { ok: false, reason: 'bundle is not an object' };
|
|
1048
|
+
if (bundle.schema !== POLICY_BUNDLE_SCHEMA) return { ok: false, reason: `unrecognised schema: ${bundle.schema}` };
|
|
1049
|
+
if (!SCOPES.includes(bundle.scope)) return { ok: false, reason: `unrecognised scope: ${bundle.scope}` };
|
|
1050
|
+
const unknownKeys = Object.keys(bundle).filter(k => !BUNDLE_TOP_LEVEL_KEYS.has(k));
|
|
1051
|
+
if (unknownKeys.length) {
|
|
1052
|
+
return { ok: false, reason: `unrecognised top-level key(s) not covered by the signature: ${unknownKeys.join(', ')}` };
|
|
1053
|
+
}
|
|
1054
|
+
const sig = bundle.signature;
|
|
1055
|
+
if (!sig?.value) return { ok: false, reason: 'bundle is unsigned' };
|
|
1056
|
+
if (sig.algorithm !== 'ed25519') return { ok: false, reason: `unsupported algorithm: ${sig.algorithm}` };
|
|
1057
|
+
if (!publicKeyPem) return { ok: false, reason: 'no public key supplied' };
|
|
1058
|
+
let sigOk = false;
|
|
1059
|
+
try {
|
|
1060
|
+
sigOk = node_crypto__WEBPACK_IMPORTED_MODULE_3__.verify(null, canonicalPolicyBytes(bundle), publicKeyPem, Buffer.from(sig.value, 'base64'));
|
|
1061
|
+
} catch (e) {
|
|
1062
|
+
return { ok: false, reason: `verification error: ${e.message}` };
|
|
1063
|
+
}
|
|
1064
|
+
if (!sigOk) return { ok: false, reason: 'signature does not match the bundle contents — it was modified after signing' };
|
|
1065
|
+
if (bundle.expiresAt) {
|
|
1066
|
+
const exp = Date.parse(bundle.expiresAt);
|
|
1067
|
+
if (!Number.isFinite(exp)) return { ok: false, reason: `expiresAt is not a valid date: ${bundle.expiresAt}` };
|
|
1068
|
+
if (exp < now) return { ok: false, reason: `policy expired on ${bundle.expiresAt}` };
|
|
1069
|
+
}
|
|
1070
|
+
return { ok: true, reason: null };
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* Merge verified bundles into one effective policy, in inheritance order
|
|
1075
|
+
* (organization -> repository -> environment; most specific wins per key).
|
|
1076
|
+
* A tampered or expired bundle is EXCLUDED from the merge (rejected, not
|
|
1077
|
+
* silently dropped — its scope and reason are reported so a rejection is
|
|
1078
|
+
* visible, not just absent). `provenance[key]` names which scope's bundle
|
|
1079
|
+
* last set that key — the "effective policy is explainable" half of the
|
|
1080
|
+
* acceptance criterion.
|
|
1081
|
+
*
|
|
1082
|
+
* @param {Array<{scope: string, bundle: object}>} entries
|
|
1083
|
+
* @param {string} publicKeyPem
|
|
1084
|
+
* @returns {{effective: object, provenance: Record<string,string>,
|
|
1085
|
+
* accepted: string[], rejected: Array<{scope:string, reason:string}>}}
|
|
1086
|
+
*/
|
|
1087
|
+
function resolveEffectivePolicy(entries, publicKeyPem, { now = Date.now() } = {}) {
|
|
1088
|
+
const byScope = new Map();
|
|
1089
|
+
for (const e of entries || []) {
|
|
1090
|
+
if (e && SCOPES.includes(e.scope)) byScope.set(e.scope, e.bundle);
|
|
1091
|
+
}
|
|
1092
|
+
const effective = {};
|
|
1093
|
+
const provenance = {};
|
|
1094
|
+
const accepted = [];
|
|
1095
|
+
const rejected = [];
|
|
1096
|
+
for (const scope of INHERITANCE_ORDER) {
|
|
1097
|
+
const bundle = byScope.get(scope);
|
|
1098
|
+
if (!bundle) continue;
|
|
1099
|
+
const v = verifyPolicyBundle(bundle, publicKeyPem, { now });
|
|
1100
|
+
if (!v.ok) { rejected.push({ scope, reason: v.reason }); continue; }
|
|
1101
|
+
accepted.push(scope);
|
|
1102
|
+
for (const [k, val] of Object.entries(bundle.policy)) {
|
|
1103
|
+
effective[k] = val;
|
|
1104
|
+
provenance[k] = scope;
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
return { effective, provenance, accepted, rejected };
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* Load whichever policy bundle files exist under
|
|
1112
|
+
* `.agentic-security/policy-bundles/{organization,repository,environment}.json`.
|
|
1113
|
+
* Degrades gracefully — a missing directory or missing/malformed individual
|
|
1114
|
+
* file is simply absent from the result, never thrown. Read-first-in-try/
|
|
1115
|
+
* catch throughout (no existsSync-then-readFileSync — D-0012/D-0022).
|
|
1116
|
+
*/
|
|
1117
|
+
function loadPolicyBundles(scanRoot) {
|
|
1118
|
+
if (!scanRoot) return [];
|
|
1119
|
+
const entries = [];
|
|
1120
|
+
for (const scope of SCOPES) {
|
|
1121
|
+
let fp;
|
|
1122
|
+
try { fp = (0,_state_dir_js__WEBPACK_IMPORTED_MODULE_5__.statePath)(scanRoot, 'policy-bundles', `${scope}.json`); } catch { continue; }
|
|
1123
|
+
let raw;
|
|
1124
|
+
try { raw = node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(fp, 'utf8'); } catch { continue; }
|
|
1125
|
+
try {
|
|
1126
|
+
const bundle = JSON.parse(raw);
|
|
1127
|
+
if (bundle && typeof bundle === 'object') entries.push({ scope, bundle });
|
|
1128
|
+
} catch { /* malformed — skip, do not throw */ }
|
|
1129
|
+
}
|
|
1130
|
+
return entries;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* Read the operator-supplied public key an org distributes alongside its
|
|
1135
|
+
* bundles, from `.agentic-security/policy-bundle-public-key.pem`. Returns
|
|
1136
|
+
* null (not a throw) if absent or unreadable — resolution then rejects
|
|
1137
|
+
* every bundle for lack of a key, same as any other "no public key
|
|
1138
|
+
* supplied" case in verifyPolicyBundle.
|
|
1139
|
+
*/
|
|
1140
|
+
function loadPolicyPublicKey(scanRoot) {
|
|
1141
|
+
if (!scanRoot) return null;
|
|
1142
|
+
let fp;
|
|
1143
|
+
try { fp = (0,_state_dir_js__WEBPACK_IMPORTED_MODULE_5__.statePath)(scanRoot, 'policy-bundle-public-key.pem'); } catch { return null; }
|
|
1144
|
+
try { return node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(fp, 'utf8'); } catch { return null; }
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/**
|
|
1148
|
+
* FR-1006 ("policy drift"): does this repository's LOCALLY resolved effective
|
|
1149
|
+
* policy diverge from what the organization-scope bundle alone specifies —
|
|
1150
|
+
* and is anything the repo is relying on actually being rejected (tampered
|
|
1151
|
+
* or expired) rather than enforced? Both are GOVERNANCE gaps, distinct from
|
|
1152
|
+
* risk findings, which is the exact distinction FR-1006's acceptance
|
|
1153
|
+
* criterion asks fleet output to make.
|
|
1154
|
+
*
|
|
1155
|
+
* No bundles configured at all is a no-op (`null`), matching this session's
|
|
1156
|
+
* established convention: drift can only be reported relative to a
|
|
1157
|
+
* baseline, and a repo with no organization bundle has no baseline to drift
|
|
1158
|
+
* from.
|
|
1159
|
+
*
|
|
1160
|
+
* @returns {{accepted: string[], rejected: Array<{scope,reason}>,
|
|
1161
|
+
* overrides: Array<{key, organizationValue, effectiveValue, overriddenBy}>}
|
|
1162
|
+
* | null}
|
|
1163
|
+
*/
|
|
1164
|
+
function computePolicyDrift(scanRoot) {
|
|
1165
|
+
const entries = loadPolicyBundles(scanRoot);
|
|
1166
|
+
if (!entries.length) return null;
|
|
1167
|
+
const publicKeyPem = loadPolicyPublicKey(scanRoot);
|
|
1168
|
+
const { effective, provenance, accepted, rejected } = resolveEffectivePolicy(entries, publicKeyPem);
|
|
1169
|
+
|
|
1170
|
+
const overrides = [];
|
|
1171
|
+
const orgEntry = entries.find(e => e.scope === 'organization');
|
|
1172
|
+
if (orgEntry && accepted.includes('organization')) {
|
|
1173
|
+
for (const [key, organizationValue] of Object.entries(orgEntry.bundle.policy || {})) {
|
|
1174
|
+
const overriddenBy = provenance[key];
|
|
1175
|
+
if (overriddenBy && overriddenBy !== 'organization' && !_deepEqual(effective[key], organizationValue)) {
|
|
1176
|
+
overrides.push({ key, organizationValue, effectiveValue: effective[key], overriddenBy });
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
return { accepted, rejected, overrides };
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
function _deepEqual(a, b) {
|
|
1184
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
/***/ })
|
|
1189
|
+
|
|
1190
|
+
};
|