@clear-capabilities/agentic-security-scanner 0.162.0 → 0.163.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.
Files changed (153) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/bin/agentic-security.js +87 -0
  3. package/dist/1340.index.js +1871 -0
  4. package/dist/3477.index.js +727 -0
  5. package/dist/3499.index.js +23 -3
  6. package/dist/4113.index.js +1 -1
  7. package/dist/5014.index.js +833 -0
  8. package/dist/5756.index.js +2 -2
  9. package/dist/6257.index.js +1 -1
  10. package/dist/6907.index.js +248 -0
  11. package/dist/6994.index.js +1 -1
  12. package/dist/7033.index.js +33 -5
  13. package/dist/7439.index.js +1822 -0
  14. package/dist/7838.index.js +1 -1
  15. package/dist/9560.index.js +2318 -23
  16. package/dist/957.index.js +1 -1
  17. package/dist/9999.index.js +43 -7
  18. package/dist/agentic-security.mjs +12 -12
  19. package/dist/agentic-security.mjs.sha256 +1 -1
  20. package/package.json +33 -6
  21. package/src/capabilities/CLAUDE.md +114 -0
  22. package/src/capabilities/address.js +73 -0
  23. package/src/capabilities/attack-coverage.js +93 -0
  24. package/src/capabilities/decide.js +256 -0
  25. package/src/capabilities/delegate.js +69 -0
  26. package/src/capabilities/egress.js +20 -0
  27. package/src/capabilities/hook-advice.js +50 -0
  28. package/src/capabilities/index.js +32 -0
  29. package/src/capabilities/manifest.js +344 -0
  30. package/src/capabilities/outbound.js +189 -0
  31. package/src/capabilities/paths.js +57 -0
  32. package/src/capabilities/probes.js +211 -0
  33. package/src/capabilities/proxy.js +182 -0
  34. package/src/capabilities/reasons.js +67 -0
  35. package/src/capabilities/receipts.js +356 -0
  36. package/src/capabilities/records.js +58 -0
  37. package/src/capabilities/recovery.js +367 -0
  38. package/src/capabilities/report.js +81 -0
  39. package/src/capabilities/runner.js +236 -0
  40. package/src/capabilities/secrets.js +26 -0
  41. package/src/capabilities/tool-gate.js +119 -0
  42. package/src/capabilities/tool-registry.js +60 -0
  43. package/src/dataflow/CLAUDE.md +29 -0
  44. package/src/dataflow/catalog.js +21 -0
  45. package/src/dataflow/engine.js +20 -0
  46. package/src/dataflow/guard-dominance.js +352 -0
  47. package/src/discovery/CLAUDE.md +9 -0
  48. package/src/discovery/index.js +15 -1
  49. package/src/discovery/lenses.js +18 -0
  50. package/src/engine.js +80 -15
  51. package/src/integrations/index.js +5 -2
  52. package/src/ir/CLAUDE.md +14 -0
  53. package/src/ir/implicit-handler-params.js +74 -0
  54. package/src/ir/parser-cs.js +20 -0
  55. package/src/ir/parser-go.js +30 -13
  56. package/src/ir/parser-java.js +11 -0
  57. package/src/language/nix-eval-isolation.js +33 -5
  58. package/src/lineage/CLAUDE.md +31 -0
  59. package/src/lineage/deployment/adapter-cloud.js +202 -0
  60. package/src/lineage/deployment/adapter-compose.js +90 -0
  61. package/src/lineage/deployment/adapter-kit.js +142 -0
  62. package/src/lineage/deployment/adapter-kubernetes.js +247 -0
  63. package/src/lineage/deployment/attack-paths.js +443 -0
  64. package/src/lineage/deployment/boundaries-run.js +223 -0
  65. package/src/lineage/deployment/boundary-graph.js +567 -0
  66. package/src/lineage/deployment/cross-repo.js +253 -0
  67. package/src/lineage/deployment/drift.js +383 -0
  68. package/src/lineage/deployment/ingest.js +221 -0
  69. package/src/lineage/deployment/projection.js +244 -0
  70. package/src/lineage/deployment/trace-correlation.js +541 -0
  71. package/src/lineage/source-registry.js +19 -0
  72. package/src/mcp/CLAUDE.md +5 -2
  73. package/src/mcp/invariant-tools.js +64 -0
  74. package/src/mcp/portfolio-tools.js +79 -0
  75. package/src/mcp/redact.js +15 -0
  76. package/src/mcp/server.js +24 -3
  77. package/src/mcp/tools.js +13 -1
  78. package/src/posture/CLAUDE.md +332 -1
  79. package/src/posture/accuracy-scorecard.js +95 -4
  80. package/src/posture/assurance/rollback.js +214 -0
  81. package/src/posture/assurance/rollout.js +200 -0
  82. package/src/posture/business-logic.js +26 -4
  83. package/src/posture/evaluation/bakeoff.js +148 -0
  84. package/src/posture/evaluation/custody.js +200 -0
  85. package/src/posture/evaluation/deployment-ablation.js +416 -0
  86. package/src/posture/evaluation/dev-cases.js +65 -0
  87. package/src/posture/evaluation/economics.js +85 -0
  88. package/src/posture/evaluation/gates.js +161 -0
  89. package/src/posture/evaluation/grouping.js +101 -0
  90. package/src/posture/evaluation/interval.js +132 -0
  91. package/src/posture/evaluation/invariant-ablation.js +325 -0
  92. package/src/posture/evaluation/labels.js +200 -0
  93. package/src/posture/evaluation/protocol.js +308 -0
  94. package/src/posture/evaluation/release-gate.js +209 -0
  95. package/src/posture/evaluation/report.js +158 -0
  96. package/src/posture/evaluation/runner.js +273 -0
  97. package/src/posture/evaluation/scan-child.js +80 -0
  98. package/src/posture/evaluation/score.js +174 -0
  99. package/src/posture/evaluation/synthetic.js +90 -0
  100. package/src/posture/evaluation/why-missed.js +219 -0
  101. package/src/posture/fleet.js +11 -2
  102. package/src/posture/invariants/cli.js +95 -0
  103. package/src/posture/invariants/coverage.js +164 -0
  104. package/src/posture/invariants/export.js +178 -0
  105. package/src/posture/invariants/expressions.js +46 -0
  106. package/src/posture/invariants/lifecycle.js +289 -0
  107. package/src/posture/invariants/project-input.js +55 -0
  108. package/src/posture/invariants/repair.js +340 -0
  109. package/src/posture/invariants/run.js +45 -0
  110. package/src/posture/invariants/scenarios.js +311 -0
  111. package/src/posture/invariants/schema.js +167 -0
  112. package/src/posture/invariants/shrink.js +407 -0
  113. package/src/posture/invariants/state-assertions.js +165 -0
  114. package/src/posture/logic-claims.js +12 -0
  115. package/src/posture/model-routing.js +40 -0
  116. package/src/posture/oracles/adapters.js +172 -1
  117. package/src/posture/oracles/conformance.js +3 -2
  118. package/src/posture/oracles/oracle.js +23 -3
  119. package/src/posture/oracles/registry.js +1 -1
  120. package/src/posture/portfolio/backend.js +279 -0
  121. package/src/posture/portfolio/bundle.js +314 -0
  122. package/src/posture/portfolio/cli.js +142 -0
  123. package/src/posture/portfolio/manifest.js +263 -0
  124. package/src/posture/portfolio/progress.js +267 -0
  125. package/src/posture/portfolio/resume.js +130 -0
  126. package/src/posture/portfolio/retention.js +207 -0
  127. package/src/posture/portfolio/scheduler.js +488 -0
  128. package/src/posture/portfolio/signing.js +155 -0
  129. package/src/posture/portfolio/wording.js +59 -0
  130. package/src/posture/portfolio/work-units.js +405 -0
  131. package/src/posture/replay/replay.js +1 -0
  132. package/src/posture/routing/adapter.js +140 -0
  133. package/src/posture/routing/calibration.js +278 -0
  134. package/src/posture/routing/canary.js +141 -0
  135. package/src/posture/routing/control.js +35 -0
  136. package/src/posture/routing/decide.js +211 -0
  137. package/src/posture/routing/drift.js +137 -0
  138. package/src/posture/routing/economics.js +185 -0
  139. package/src/posture/routing/feedback.js +207 -0
  140. package/src/posture/routing/index.js +31 -0
  141. package/src/posture/routing/outcomes.js +281 -0
  142. package/src/posture/routing/promotion.js +291 -0
  143. package/src/posture/routing/receipts.js +59 -0
  144. package/src/posture/routing/report.js +142 -0
  145. package/src/posture/routing/shadow.js +53 -0
  146. package/src/posture/specification-mining.js +26 -0
  147. package/src/posture/verification/patch-negative.js +16 -4
  148. package/src/posture/verification/patch-promotion.js +10 -2
  149. package/src/report/index.js +51 -1
  150. package/src/sandbox/CLAUDE.md +10 -0
  151. package/src/sandbox/backend-userspace.js +91 -13
  152. package/dist/5613.index.js +0 -500
  153. package/dist/6239.index.js +0 -469
@@ -0,0 +1,202 @@
1
+ // adapter-cloud.js: Terraform plan JSON and IAM policy documents into
2
+ // deployment boundary nodes and edges (X-302).
3
+ //
4
+ // Input is a plan that someone already produced (`terraform show -json`); this
5
+ // adapter never runs terraform, never evaluates HCL and never contacts a
6
+ // provider. Values the plan itself marks as unknown after apply are not
7
+ // guessed: the relation becomes an unresolved node plus a gap. Policy
8
+ // documents are read with JSON.parse; `NotAction` / `NotResource` statements
9
+ // and conditions it cannot interpret are reported, not approximated.
10
+
11
+ import { adapterContext, isObject, asArray, str } from './adapter-kit.js';
12
+
13
+ export const TERRAFORM_PARSER = 'terraform-plan-json';
14
+ export const TERRAFORM_PARSER_VERSION = '1';
15
+ export const IAM_PARSER = 'iam-policy-json';
16
+ export const IAM_PARSER_VERSION = '1';
17
+
18
+ const MAX_STATEMENTS = 200;
19
+ const MAX_RESOURCES_PER_STATEMENT = 50;
20
+
21
+ function toList(v) { return typeof v === 'string' ? [v] : Array.isArray(v) ? v.filter(x => typeof x === 'string') : []; }
22
+
23
+ /** Emit grants edges for every statement of a policy document. Returns the number of statements read. */
24
+ function applyPolicy(kit, identity, policy, subject) {
25
+ const statements = Array.isArray(policy?.Statement) ? policy.Statement : isObject(policy?.Statement) ? [policy.Statement] : null;
26
+ if (!statements) { kit.gap('malformed-input', subject, 'policy document has no Statement'); return 0; }
27
+ let read = 0;
28
+ for (const st of statements.slice(0, MAX_STATEMENTS)) {
29
+ if (!isObject(st)) { kit.gap('malformed-input', subject, 'a policy statement that is not an object is skipped'); continue; }
30
+ if (st.NotAction !== undefined || st.NotResource !== undefined || st.NotPrincipal !== undefined) {
31
+ kit.gap('unsupported-syntax', subject, 'NotAction, NotResource and NotPrincipal statements are not interpreted; the statement is skipped');
32
+ continue;
33
+ }
34
+ const effect = st.Effect === 'Allow' ? 'allow' : st.Effect === 'Deny' ? 'deny' : null;
35
+ if (!effect) { kit.gap('malformed-input', subject, `statement Effect must be Allow or Deny, got ${JSON.stringify(st.Effect)}`); continue; }
36
+ const actions = toList(st.Action).sort();
37
+ const resources = toList(st.Resource);
38
+ if (actions.length === 0 || resources.length === 0) { kit.gap('malformed-input', subject, 'a statement without Action and Resource is skipped'); continue; }
39
+ const conditioned = isObject(st.Condition) && Object.keys(st.Condition).length > 0;
40
+ read += 1;
41
+ if (resources.length > MAX_RESOURCES_PER_STATEMENT) kit.gap('limit-exceeded', subject, `a statement lists ${resources.length} resources; only the first ${MAX_RESOURCES_PER_STATEMENT} are read`);
42
+ for (const r of resources.slice(0, MAX_RESOURCES_PER_STATEMENT)) {
43
+ const target = kit.node('resource', `aws/${r}`, { trustZone: 'internal', attrs: r === '*' ? { wildcard: true } : {} });
44
+ const attrs = { actions: actions.join(',').slice(0, 250) };
45
+ if (conditioned) attrs.conditioned = true;
46
+ kit.edge('grants', identity, target, {
47
+ effect, confidence: conditioned ? 'low' : 'high', discriminator: `${actions.join(',')}${conditioned ? ';cond' : ''}`.slice(0, 250), attrs,
48
+ });
49
+ }
50
+ }
51
+ if (statements.length > MAX_STATEMENTS) kit.gap('limit-exceeded', subject, `policy has ${statements.length} statements; only the first ${MAX_STATEMENTS} are read`);
52
+ return read;
53
+ }
54
+
55
+ export function parseIamPolicy(text, fileCtx) {
56
+ const kit = adapterContext({ ...fileCtx, parser: IAM_PARSER, parserVersion: IAM_PARSER_VERSION });
57
+ let doc;
58
+ try { doc = JSON.parse(text); } catch (e) {
59
+ kit.gap('malformed-input', fileCtx.file, `not valid JSON (${String(e.message).split('\n')[0]})`);
60
+ return kit.result();
61
+ }
62
+ const identityName = str(fileCtx.identity);
63
+ let identity;
64
+ if (identityName) identity = kit.node('identity', `iam/${identityName}`, { trustZone: 'internal' });
65
+ else {
66
+ identity = kit.unresolved('identity', `iam/policy:${fileCtx.file}`, 'the policy document is not attached to a named identity', { trustZone: 'unknown' });
67
+ kit.gap('unresolved-identity', fileCtx.file, 'the policy document is not attached to a named identity; supply the identity it belongs to');
68
+ }
69
+ applyPolicy(kit, identity, doc, fileCtx.file);
70
+ return kit.result();
71
+ }
72
+
73
+ function roleNameFromArn(arn) {
74
+ const m = /^arn:[^:]*:iam::[^:]*:role\/(?:.*\/)?([^/]+)$/.exec(arn ?? '');
75
+ return m ? m[1] : null;
76
+ }
77
+
78
+ export function parseTerraformPlan(text, fileCtx) {
79
+ const kit = adapterContext({ ...fileCtx, parser: TERRAFORM_PARSER, parserVersion: TERRAFORM_PARSER_VERSION });
80
+ let plan;
81
+ try { plan = JSON.parse(text); } catch (e) {
82
+ kit.gap('malformed-input', fileCtx.file, `not valid JSON (${String(e.message).split('\n')[0]})`);
83
+ return kit.result();
84
+ }
85
+ if (!isObject(plan) || !str(plan.format_version) || !Array.isArray(plan.resource_changes)) {
86
+ kit.gap('unsupported-format', fileCtx.file, 'not a terraform plan in JSON form (needs format_version and resource_changes)');
87
+ return kit.result();
88
+ }
89
+ const resources = new Map();
90
+ for (const rc of plan.resource_changes) {
91
+ if (!isObject(rc) || !str(rc.address) || !isObject(rc.change)) continue;
92
+ const actions = asArray(rc.change.actions);
93
+ if (actions.includes('delete') && !actions.includes('create')) continue;
94
+ resources.set(rc.address, { type: rc.type, name: rc.name, after: isObject(rc.change.after) ? rc.change.after : {}, unknown: isObject(rc.change.after_unknown) ? rc.change.after_unknown : {} });
95
+ }
96
+ const config = new Map();
97
+ for (const r of asArray(plan.configuration?.root_module?.resources)) {
98
+ if (isObject(r) && str(r.address)) config.set(r.address, isObject(r.expressions) ? r.expressions : {});
99
+ }
100
+ const refTarget = (address, field, wantType) => {
101
+ const refs = asArray(config.get(address)?.[field]?.references);
102
+ for (const ref of refs) {
103
+ const m = /^([a-z0-9_]+\.[A-Za-z0-9_-]+)(?:\.|$)/.exec(ref);
104
+ if (m && resources.get(m[1])?.type === wantType) return m[1];
105
+ }
106
+ return null;
107
+ };
108
+
109
+ const identityNodes = new Map();
110
+ const roleNode = (address) => {
111
+ if (identityNodes.has(address)) return identityNodes.get(address);
112
+ const r = resources.get(address);
113
+ const name = str(r?.after?.name);
114
+ let node;
115
+ if (name && r.unknown.name !== true) node = kit.node('identity', `iam-role/${name}`, { trustZone: 'internal' });
116
+ else {
117
+ node = kit.unresolved('identity', `iam-role/${address}`, 'the role name is only known after apply', { trustZone: 'unknown' });
118
+ kit.gap('unresolved-identity', address, 'the role name is only known after apply');
119
+ }
120
+ identityNodes.set(address, node);
121
+ return node;
122
+ };
123
+ const roleByArn = (arn, subject) => {
124
+ const n = roleNameFromArn(arn);
125
+ if (!n) { kit.gap('unresolved-identity', subject, 'the role reference is not a literal role ARN and has no resolvable reference'); return kit.unresolved('identity', `iam-role/${subject}`, 'unresolved role reference', { trustZone: 'unknown' }); }
126
+ return kit.unresolved('identity', `iam-role/${n}`, 'the role is not defined in this plan', { trustZone: 'unknown' });
127
+ };
128
+
129
+ const handled = new Set(['aws_iam_role', 'aws_iam_policy', 'aws_iam_role_policy', 'aws_iam_role_policy_attachment', 'aws_lambda_function', 'aws_s3_bucket', 'aws_security_group']);
130
+ const unsupportedTypes = new Set();
131
+ const policies = new Map();
132
+ for (const [address, r] of resources) {
133
+ if (r.type === 'aws_iam_policy' || r.type === 'aws_iam_role_policy') {
134
+ let doc = null;
135
+ if (typeof r.after.policy === 'string' && r.unknown.policy !== true) { try { doc = JSON.parse(r.after.policy); } catch { doc = null; } }
136
+ policies.set(address, doc);
137
+ }
138
+ if (!handled.has(r.type)) unsupportedTypes.add(r.type);
139
+ }
140
+
141
+ for (const [address, r] of resources) {
142
+ switch (r.type) {
143
+ case 'aws_iam_role': roleNode(address); break;
144
+ case 'aws_iam_role_policy': {
145
+ const roleAddr = refTarget(address, 'role', 'aws_iam_role');
146
+ const ident = roleAddr ? roleNode(roleAddr) : (str(r.after.role) && r.unknown.role !== true ? kit.node('identity', `iam-role/${r.after.role}`, { trustZone: 'internal' }) : kit.unresolved('identity', `iam-role/${address}`, 'the role is only known after apply', { trustZone: 'unknown' }));
147
+ const doc = policies.get(address);
148
+ if (!doc) kit.gap('unresolved-identity', address, 'the policy document is only known after apply or is not valid JSON; its grants are unknown');
149
+ else applyPolicy(kit, ident, doc, address);
150
+ break;
151
+ }
152
+ case 'aws_iam_role_policy_attachment': {
153
+ const roleAddr = refTarget(address, 'role', 'aws_iam_role');
154
+ const polAddr = refTarget(address, 'policy_arn', 'aws_iam_policy');
155
+ const ident = roleAddr ? roleNode(roleAddr) : (str(r.after.role) && r.unknown.role !== true ? kit.node('identity', `iam-role/${r.after.role}`, { trustZone: 'internal' }) : kit.unresolved('identity', `iam-role/${address}`, 'the role is only known after apply', { trustZone: 'unknown' }));
156
+ if (!polAddr) {
157
+ kit.gap('unresolved-identity', address, `attached policy ${typeof r.after.policy_arn === 'string' ? r.after.policy_arn : '(known after apply)'} is not defined in this plan, so what it grants is unknown`);
158
+ const target = kit.unresolved('resource', `aws/policy:${typeof r.after.policy_arn === 'string' ? r.after.policy_arn : address}`, 'the attached policy is not defined in this plan', { trustZone: 'unknown' });
159
+ kit.edge('grants', ident, target, { effect: 'allow', confidence: 'low', discriminator: 'attached-policy' });
160
+ } else if (!policies.get(polAddr)) {
161
+ kit.gap('unresolved-identity', polAddr, 'the policy document is only known after apply or is not valid JSON; its grants are unknown');
162
+ } else applyPolicy(kit, ident, policies.get(polAddr), address);
163
+ break;
164
+ }
165
+ case 'aws_lambda_function': {
166
+ const name = str(r.after.function_name);
167
+ const node = name && r.unknown.function_name !== true
168
+ ? kit.node('service', `lambda/${name}`, { trustZone: 'internal' })
169
+ : kit.unresolved('service', `lambda/${address}`, 'the function name is only known after apply', { trustZone: 'unknown' });
170
+ const roleAddr = refTarget(address, 'role', 'aws_iam_role');
171
+ const ident = roleAddr ? roleNode(roleAddr) : (typeof r.after.role === 'string' ? roleByArn(r.after.role, address) : roleByArn(null, address));
172
+ kit.edge('assumes', node, ident, { confidence: roleAddr ? 'high' : 'low' });
173
+ break;
174
+ }
175
+ case 'aws_s3_bucket': {
176
+ const b = str(r.after.bucket);
177
+ if (b && r.unknown.bucket !== true) kit.node('resource', `s3/${b}`, { trustZone: r.after.acl === 'public-read' || r.after.acl === 'public-read-write' ? 'public' : 'internal', attrs: { acl: typeof r.after.acl === 'string' ? r.after.acl : 'unspecified' } });
178
+ else kit.unresolved('resource', `s3/${address}`, 'the bucket name is only known after apply', { trustZone: 'unknown' });
179
+ break;
180
+ }
181
+ case 'aws_security_group': {
182
+ const name = str(r.after.name) ?? address;
183
+ const sg = kit.node('resource', `sg/${name}`, { trustZone: 'internal' });
184
+ for (const rule of asArray(r.after.ingress)) {
185
+ const cidrs = asArray(rule?.cidr_blocks).filter(c => typeof c === 'string');
186
+ const ports = `${rule?.protocol ?? '-1'}:${rule?.from_port ?? 0}-${rule?.to_port ?? 0}`;
187
+ for (const cidr of cidrs) {
188
+ const src = kit.node('route', `cidr/${cidr}`, { trustZone: cidr === '0.0.0.0/0' ? 'public' : 'edge', attrs: { cidr } });
189
+ kit.edge('network-allows', src, sg, { confidence: 'high', discriminator: ports, attrs: { ports } });
190
+ }
191
+ }
192
+ break;
193
+ }
194
+ default: break;
195
+ }
196
+ }
197
+ if (unsupportedTypes.size) {
198
+ const list = [...unsupportedTypes].sort();
199
+ kit.gap('unsupported-format', fileCtx.file, `resource types not interpreted by this adapter: ${list.slice(0, 10).join(', ')}${list.length > 10 ? `, and ${list.length - 10} more` : ''}`);
200
+ }
201
+ return kit.result();
202
+ }
@@ -0,0 +1,90 @@
1
+ // adapter-compose.js: container composition files into deployment boundary
2
+ // nodes and edges (X-302).
3
+ //
4
+ // A service is a node. A published port is an entry route into it, public
5
+ // unless it is bound to loopback. `depends_on` and `links` are declared
6
+ // dependencies: they are recorded as `depends-on`, never as `calls`, because a
7
+ // dependency is a startup ordering or a reachability hint and says nothing
8
+ // about a call actually being made. Environment variables, env files, secrets
9
+ // and build arguments are never read, so a credential in a compose file cannot
10
+ // reach the graph.
11
+
12
+ import { adapterContext, parseYamlDocuments, isObject, asArray, str } from './adapter-kit.js';
13
+
14
+ export const COMPOSE_PARSER = 'compose-file';
15
+ export const COMPOSE_PARSER_VERSION = '1';
16
+
17
+ function parsePort(p) {
18
+ if (typeof p === 'number') return { published: String(p), target: String(p), hostIp: null };
19
+ if (typeof p === 'string') {
20
+ let t = p.trim().replace(/\/(tcp|udp|sctp)$/i, '');
21
+ let hostIp = null;
22
+ const v6 = /^\[([^\]]+)\]:(.*)$/.exec(t);
23
+ if (v6) { hostIp = `[${v6[1]}]`; t = v6[2]; }
24
+ const parts = t.split(':');
25
+ const port = /^\d+(-\d+)?$/;
26
+ if (parts.some(x => !port.test(x) && !(parts.length === 3 && x === parts[0] && /^[0-9.]+$/.test(x)))) return null;
27
+ if (parts.length === 1) return { published: null, target: parts[0], hostIp };
28
+ if (parts.length === 2) return { published: parts[0], target: parts[1], hostIp };
29
+ if (parts.length === 3 && !hostIp) return { published: parts[1], target: parts[2], hostIp: parts[0] };
30
+ return null;
31
+ }
32
+ if (isObject(p)) {
33
+ const target = p.target != null ? String(p.target) : null;
34
+ if (!target) return null;
35
+ return { published: p.published != null ? String(p.published) : null, target, hostIp: str(p.host_ip) };
36
+ }
37
+ return null;
38
+ }
39
+
40
+ export function parseCompose(text, fileCtx) {
41
+ const kit = adapterContext({ ...fileCtx, parser: COMPOSE_PARSER, parserVersion: COMPOSE_PARSER_VERSION });
42
+ const parsed = parseYamlDocuments(text, fileCtx.file);
43
+ if (parsed.gap) { kit.gaps.push(parsed.gap); return kit.result(); }
44
+ const doc = parsed.docs[0];
45
+ if (!isObject(doc) || !isObject(doc.services)) {
46
+ kit.gap('unsupported-format', fileCtx.file, 'no top-level services mapping; not a compose file');
47
+ return kit.result();
48
+ }
49
+ const declared = new Set(Object.keys(doc.services));
50
+ const nodes = new Map();
51
+ const nodeFor = (name) => nodes.get(name);
52
+ const networks = isObject(doc.networks) ? doc.networks : {};
53
+
54
+ for (const [name, svc] of Object.entries(doc.services)) {
55
+ if (!isObject(svc)) { kit.gap('malformed-input', name, 'a service that is not a mapping is skipped'); continue; }
56
+ if (Object.hasOwn(svc, '<<')) kit.gap('unsupported-syntax', name, "YAML merge key '<<' is not expanded; fields inherited through it are not read");
57
+ const tenant = isObject(svc.labels) && typeof svc.labels.tenant === 'string' && /^[A-Za-z0-9._-]{1,64}$/.test(svc.labels.tenant) ? svc.labels.tenant : null;
58
+ const attrs = {};
59
+ const nets = (Array.isArray(svc.networks) ? svc.networks : isObject(svc.networks) ? Object.keys(svc.networks) : []).filter(n => typeof n === 'string').sort();
60
+ if (nets.length) attrs.networks = nets.join(',');
61
+ if (nets.length && nets.every(n => isObject(networks[n]) && networks[n].internal === true)) attrs.networkInternal = true;
62
+ if (svc.network_mode === 'host') attrs.networkMode = 'host';
63
+ nodes.set(name, kit.node('service', name, { tenant, trustZone: 'internal', attrs }));
64
+ }
65
+
66
+ for (const [name, svc] of Object.entries(doc.services)) {
67
+ const node = nodeFor(name);
68
+ if (!node) continue;
69
+ for (const raw of asArray(svc.ports)) {
70
+ const port = parsePort(raw);
71
+ if (!port) { kit.gap('malformed-input', name, 'a port mapping that cannot be read is skipped'); continue; }
72
+ if (port.published === null) continue; // container port only, not published to the host
73
+ const loop = port.hostIp !== null && (/^127\./.test(port.hostIp) || port.hostIp === '[::1]');
74
+ const route = kit.node('route', `port/${name}:${port.published}`, { tenant: node.tenant, trustZone: loop ? 'internal' : 'public', attrs: { published: port.published, target: port.target, bind: port.hostIp ?? 'all-interfaces' } });
75
+ kit.edge('routes-to', route, node, { confidence: 'high' });
76
+ }
77
+ const deps = Array.isArray(svc.depends_on) ? svc.depends_on : isObject(svc.depends_on) ? Object.keys(svc.depends_on) : [];
78
+ const links = [...asArray(svc.links), ...asArray(svc.external_links)].map(l => (typeof l === 'string' ? l.split(':')[0] : null));
79
+ for (const d of [...deps, ...links]) {
80
+ if (typeof d !== 'string' || !d) continue;
81
+ if (declared.has(d) && nodeFor(d)) kit.edge('depends-on', node, nodeFor(d), { confidence: 'medium' });
82
+ else {
83
+ const miss = kit.unresolved('service', d, 'the dependency is not defined in this compose file', { trustZone: 'unknown' });
84
+ kit.edge('depends-on', node, miss, { confidence: 'low' });
85
+ kit.gap('unresolved-identity', name, `depends on '${d}', which this compose file does not define`);
86
+ }
87
+ }
88
+ }
89
+ return kit.result();
90
+ }
@@ -0,0 +1,142 @@
1
+ // adapter-kit.js: shared plumbing for the deployment configuration adapters
2
+ // (X-302). Every adapter is a pure function from text to `{ nodes, edges, gaps }`
3
+ // built through one context, so that three things hold for every adapter
4
+ // without each one remembering them:
5
+ //
6
+ // - every edge a static adapter emits is `static-config` provenance and
7
+ // carries the exact file reference, digest, parser name and parser version
8
+ // it came from (X-302.AC03);
9
+ // - environment, tenant and owning repository are stamped uniformly, so two
10
+ // environments never share a node;
11
+ // - a reference that cannot be resolved becomes an unresolved node plus a gap,
12
+ // never a guessed edge to a resolved one.
13
+ //
14
+ // Nothing here evaluates configuration. YAML is read with the scanner's safe
15
+ // loader (unknown tags are refused), JSON with JSON.parse, and policy strings
16
+ // are JSON.parse'd, never evaluated.
17
+
18
+ import { load as loadYaml } from '../../util/yaml.js';
19
+ import { nodeIdOf } from './boundary-graph.js';
20
+
21
+ const ANCHOR_LIMIT = 100;
22
+
23
+ /**
24
+ * Parse one YAML document, or several separated by `---`. Never throws.
25
+ * Returns `{ docs }` or `{ gap }` where the gap is a typed unsupported or
26
+ * malformed result. YAML merge keys are not expanded by the loader, so a
27
+ * document that uses `<<` is reported by the adapter that needs the key.
28
+ */
29
+ export function parseYamlDocuments(text, file) {
30
+ const aliasCount = (text.match(/(^|[\s\[{,:-])\*[A-Za-z0-9_-]+/g) || []).length;
31
+ if (aliasCount > ANCHOR_LIMIT) {
32
+ return { gap: { code: 'unsupported-syntax', subject: file, file, message: `YAML uses ${aliasCount} aliases, over the limit of ${ANCHOR_LIMIT}; the file is not read` } };
33
+ }
34
+ const chunks = text.split(/^---[ \t]*$/m);
35
+ const docs = [];
36
+ for (const chunk of chunks) {
37
+ let doc;
38
+ try { doc = loadYaml(chunk); } catch (e) {
39
+ return { gap: { code: 'malformed-input', subject: file, file, message: `not valid YAML (${String(e.message).split('\n')[0]})` } };
40
+ }
41
+ if (doc !== undefined && doc !== null) docs.push(doc);
42
+ }
43
+ return { docs };
44
+ }
45
+
46
+ export function isObject(v) { return v !== null && typeof v === 'object' && !Array.isArray(v); }
47
+ export function asArray(v) { return Array.isArray(v) ? v : []; }
48
+ export function str(v) { return typeof v === 'string' && v.length > 0 ? v : null; }
49
+
50
+ /** True when every entry of `selector` is present with the same value in `labels`. An empty selector selects nothing. */
51
+ export function selectorMatches(selector, labels) {
52
+ if (!isObject(selector) || !isObject(labels)) return false;
53
+ const entries = Object.entries(selector);
54
+ if (entries.length === 0) return false;
55
+ return entries.every(([k, v]) => Object.hasOwn(labels, k) && String(labels[k]) === String(v));
56
+ }
57
+
58
+ /**
59
+ * @param {object} p
60
+ * @param {string} p.file path as supplied, relative to the ingest root
61
+ * @param {string} p.digest sha256 of the text that was parsed
62
+ * @param {string} p.environment deployment environment these files describe
63
+ * @param {string|null} p.repository
64
+ * @param {string|null} p.revision exact commit, when known
65
+ * @param {string} p.parser
66
+ * @param {string} p.parserVersion
67
+ */
68
+ export function adapterContext({ file, digest, environment, repository, revision, parser, parserVersion }) {
69
+ const nodes = [];
70
+ const edges = [];
71
+ const gaps = [];
72
+ const ref = { file, digest };
73
+ const source = { file, digest, parser, parserVersion };
74
+
75
+ const kit = {
76
+ nodes, edges, gaps, environment, repository, file,
77
+
78
+ /** Declare a node this file defines. Adds the structural environment, repository and tenant edges. */
79
+ node(kind, name, o = {}) {
80
+ const tenant = o.tenant ?? null;
81
+ const n = {
82
+ kind, name, environment, tenant, repository: o.resolved === false ? null : repository,
83
+ trustZone: o.trustZone ?? 'unknown', resolved: o.resolved !== false, unresolvedReason: o.unresolvedReason ?? null,
84
+ attrs: o.attrs ?? {}, declaredBy: o.resolved === false ? [] : [ref],
85
+ };
86
+ nodes.push(n);
87
+ if (o.resolved !== false) {
88
+ const env = { kind: 'environment', name: environment, environment, tenant: null, repository: null, trustZone: 'unknown', resolved: true, attrs: {}, declaredBy: [ref] };
89
+ nodes.push(env);
90
+ kit.structural('deployed-in', n, env);
91
+ if (repository) {
92
+ const repo = { kind: 'repository', name: repository, environment: null, tenant: null, repository, trustZone: 'unknown', resolved: true, attrs: {}, declaredBy: [ref] };
93
+ nodes.push(repo);
94
+ kit.structural('defined-in', n, repo);
95
+ }
96
+ if (tenant) {
97
+ const t = { kind: 'tenant', name: tenant, environment, tenant, repository: null, trustZone: 'unknown', resolved: true, attrs: {}, declaredBy: [ref] };
98
+ nodes.push(t);
99
+ kit.structural('scoped-to', n, t);
100
+ }
101
+ }
102
+ return n;
103
+ },
104
+
105
+ /** A reference this file makes to something it does not define and cannot resolve. */
106
+ unresolved(kind, name, reason, o = {}) {
107
+ return kit.node(kind, name, { ...o, resolved: false, unresolvedReason: reason });
108
+ },
109
+
110
+ structural(relation, from, to) {
111
+ edges.push(kit.baseEdge(relation, from, to, { effect: 'none', confidence: 'high' }));
112
+ },
113
+
114
+ baseEdge(relation, from, to, o = {}) {
115
+ return {
116
+ relation, from: idOf(from), to: idOf(to), environment: from.environment ?? to.environment ?? null, tenant: o.tenant ?? null,
117
+ effect: o.effect ?? 'none', provenance: 'static-config', confidence: o.confidence ?? 'medium',
118
+ pathState: o.effect === 'deny' ? 'blocked' : (o.pathState ?? 'possible'),
119
+ observationInterval: null, completeness: null, sourceRevision: revision ?? null, source,
120
+ discriminator: o.discriminator ?? null, crossRepo: null, attrs: o.attrs ?? {},
121
+ _fromNode: from, _toNode: to,
122
+ };
123
+ },
124
+
125
+ /** Add a relation between two nodes from this file. */
126
+ edge(relation, from, to, o = {}) {
127
+ edges.push(kit.baseEdge(relation, from, to, o));
128
+ },
129
+
130
+ gap(code, subject, message) { gaps.push({ code, subject, file, message }); },
131
+
132
+ result() {
133
+ // Drop the private endpoint references now that the ids are computed.
134
+ return { nodes, edges: edges.map(({ _fromNode, _toNode, ...e }) => e), gaps };
135
+ },
136
+ };
137
+ return kit;
138
+ }
139
+
140
+ function idOf(node) {
141
+ return nodeIdOf({ kind: node.kind, name: node.name, environment: node.kind === 'environment' ? node.name : (node.environment ?? null), tenant: node.kind === 'tenant' ? node.name : (node.tenant ?? null) });
142
+ }