flecto 2.0.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +533 -0
- package/README.md +345 -211
- package/index.js +826 -77
- package/package.json +9 -7
- package/schemas/flecto-policy-pack-2.0.json +129 -0
- package/src/alerter.js +24 -6
- package/src/config.js +135 -9
- package/src/differ.js +154 -47
- package/src/documents.js +106 -0
- package/src/encrypted.js +573 -0
- package/src/notifiers.js +430 -0
- package/src/packs/compose.json +45 -0
- package/src/packs/default.json +23 -1
- package/src/packs/kubernetes.json +112 -0
- package/src/packs/node-runtime.json +44 -0
- package/src/packs/sops.json +61 -0
- package/src/packs/strict-prod.json +11 -1
- package/src/packs/terraform.json +120 -0
- package/src/parser.js +189 -20
- package/src/policy-test.js +124 -0
- package/src/policy.js +815 -30
- package/src/pr-comment.js +480 -0
- package/src/renderer.js +75 -18
- package/src/report.js +653 -0
- package/src/secrets.js +316 -0
- package/src/terraform.js +500 -0
- package/src/watcher.js +27 -15
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "sops",
|
|
3
|
+
"rules": [
|
|
4
|
+
{
|
|
5
|
+
"id": "sops-file-decrypted",
|
|
6
|
+
"severity": "error",
|
|
7
|
+
"when": ["changed"],
|
|
8
|
+
"match": { "pathEquals": "<encryption>" },
|
|
9
|
+
"afterEquals": "plaintext",
|
|
10
|
+
"message": "This file was encrypted and is now plaintext. Treat every value in it as exposed: rotate the secrets, then restore encryption before merging."
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"id": "sops-value-decrypted",
|
|
14
|
+
"severity": "error",
|
|
15
|
+
"when": ["changed"],
|
|
16
|
+
"afterEquals": "<no longer encrypted>",
|
|
17
|
+
"messageTemplate": "{path} was an encrypted value and is now stored in the clear. Rotate it and re-encrypt the file."
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"id": "sops-recipient-added",
|
|
21
|
+
"severity": "error",
|
|
22
|
+
"when": ["added"],
|
|
23
|
+
"match": {
|
|
24
|
+
"path": "(?:^|\\.)sops\\.(kms|gcp_kms|azure_kv|hc_vault|age|pgp)(?:$|[.\\[])"
|
|
25
|
+
},
|
|
26
|
+
"messageTemplate": "A decryption recipient was added at {path}. That key can now decrypt this file — confirm it is authorized and that the change was intended."
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"id": "sops-recipient-removed",
|
|
30
|
+
"severity": "warn",
|
|
31
|
+
"when": ["removed"],
|
|
32
|
+
"match": {
|
|
33
|
+
"path": "(?:^|\\.)sops\\.(kms|gcp_kms|azure_kv|hc_vault|age|pgp)(?:$|[.\\[])"
|
|
34
|
+
},
|
|
35
|
+
"messageTemplate": "A decryption recipient was removed at {path}. Anything that key already read stays readable to its holder — rotate the secrets if the removal was a revocation."
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"id": "sops-mac-changed-without-value-change",
|
|
39
|
+
"severity": "warn",
|
|
40
|
+
"when": ["changed"],
|
|
41
|
+
"match": { "pathEquals": "<encryption.mac>" },
|
|
42
|
+
"message": "The SOPS MAC changed but no value it covers did. A normal edit moves both together, so this points at a hand-edited or tampered metadata block."
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"id": "sops-file-encrypted",
|
|
46
|
+
"severity": "info",
|
|
47
|
+
"when": ["changed"],
|
|
48
|
+
"match": { "pathEquals": "<encryption>" },
|
|
49
|
+
"beforeEquals": "plaintext",
|
|
50
|
+
"message": "This file is now encrypted. Confirm the values it held while plaintext were rotated — git history still has them."
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"id": "sops-creation-rule-recipients-changed",
|
|
54
|
+
"severity": "warn",
|
|
55
|
+
"match": {
|
|
56
|
+
"path": "^creation_rules\\b.*\\b(kms|gcp_kms|azure_kv|hc_vault|age|pgp|key_groups)\\b"
|
|
57
|
+
},
|
|
58
|
+
"messageTemplate": "A SOPS creation rule changed at {path}. This decides who future encryptions are readable by; existing files keep their current recipients until they are re-encrypted."
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
}
|
|
@@ -11,6 +11,16 @@
|
|
|
11
11
|
},
|
|
12
12
|
"message": "Sensitive-looking key changed in production profile. Confirm rotation and access controls."
|
|
13
13
|
},
|
|
14
|
+
{
|
|
15
|
+
"id": "secret-value-detected",
|
|
16
|
+
"severity": "error",
|
|
17
|
+
"when": ["added", "changed", "removed"],
|
|
18
|
+
"anyOf": [
|
|
19
|
+
{ "afterLooksSecret": true },
|
|
20
|
+
{ "beforeLooksSecret": true }
|
|
21
|
+
],
|
|
22
|
+
"message": "Value looks like a credential (known token format or high-entropy string) regardless of its key name. Treat it as exposed: move it to a secret store and rotate it."
|
|
23
|
+
},
|
|
14
24
|
{
|
|
15
25
|
"id": "dangerous-toggle-enabled",
|
|
16
26
|
"severity": "error",
|
|
@@ -19,7 +29,7 @@
|
|
|
19
29
|
"path": "(debug|allow_insecure|disable_tls|skip_tls_verify|permit_all)",
|
|
20
30
|
"pathFlags": "i"
|
|
21
31
|
},
|
|
22
|
-
"
|
|
32
|
+
"afterTruthy": true,
|
|
23
33
|
"message": "Dangerous toggle enabled in production profile."
|
|
24
34
|
},
|
|
25
35
|
{
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "terraform",
|
|
3
|
+
"rules": [
|
|
4
|
+
{
|
|
5
|
+
"id": "terraform-resource-replaced",
|
|
6
|
+
"severity": "error",
|
|
7
|
+
"when": ["removed"],
|
|
8
|
+
"match": {
|
|
9
|
+
"path": "\\.#action$"
|
|
10
|
+
},
|
|
11
|
+
"beforeEquals": "replace",
|
|
12
|
+
"message": "Terraform will destroy and recreate this resource. Expect downtime, a new resource id, and the loss of anything held only on the existing instance. Check which attribute forced the replacement."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"id": "terraform-stateful-resource-destroyed",
|
|
16
|
+
"severity": "error",
|
|
17
|
+
"when": ["removed"],
|
|
18
|
+
"match": {
|
|
19
|
+
"path": "(^|\\.)(aws_db_instance|aws_db_cluster_snapshot|aws_rds_cluster|aws_rds_cluster_instance|aws_dynamodb_table|aws_s3_bucket|aws_ebs_volume|aws_efs_file_system|aws_elasticache_cluster|aws_elasticache_replication_group|aws_redshift_cluster|aws_docdb_cluster|aws_neptune_cluster|google_sql_database_instance|google_storage_bucket|azurerm_storage_account|azurerm_mssql_database)\\..*\\.#action$"
|
|
20
|
+
},
|
|
21
|
+
"beforeIn": ["delete", "replace"],
|
|
22
|
+
"message": "Terraform will destroy a stateful resource. Its data does not survive. Take a final snapshot, or add a prevent_destroy lifecycle block, before applying."
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"id": "terraform-security-group-open-ingress",
|
|
26
|
+
"severity": "error",
|
|
27
|
+
"when": ["added", "changed"],
|
|
28
|
+
"afterIn": ["0.0.0.0/0", "::/0"],
|
|
29
|
+
"anyOf": [
|
|
30
|
+
{
|
|
31
|
+
"match": {
|
|
32
|
+
"path": "(^|\\.)aws_security_group\\..*\\.ingress\\[[^\\]]*\\]\\.(ipv6_)?cidr_blocks\\[[^\\]]*\\]$"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"match": {
|
|
37
|
+
"path": "(^|\\.)aws_vpc_security_group_ingress_rule\\..*\\.cidr_ipv[46]$"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
],
|
|
41
|
+
"messageTemplate": "Security group ingress will accept traffic from the whole internet ({after}). Restrict the source to a known CIDR, a prefix list, or another security group."
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"id": "terraform-iam-wildcard",
|
|
45
|
+
"severity": "error",
|
|
46
|
+
"when": ["added", "changed"],
|
|
47
|
+
"match": {
|
|
48
|
+
"path": "(^|\\.)aws_iam_[a-z_]+\\..*\\.policy$"
|
|
49
|
+
},
|
|
50
|
+
"afterMatches": "\"(Action|NotAction|Resource|NotResource)\"\\s*:\\s*(\"\\*\"|\\[[^\\]]*\"\\*\")",
|
|
51
|
+
"message": "IAM policy grants a wildcard action or resource (\"*\"). Scope it to the specific actions and ARNs the principal needs."
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"id": "terraform-s3-public-access-block-disabled",
|
|
55
|
+
"severity": "error",
|
|
56
|
+
"when": ["added", "changed", "removed"],
|
|
57
|
+
"anyOf": [
|
|
58
|
+
{
|
|
59
|
+
"match": {
|
|
60
|
+
"path": "(^|\\.)aws_s3_bucket_public_access_block\\..*\\.(block_public_acls|block_public_policy|ignore_public_acls|restrict_public_buckets)$"
|
|
61
|
+
},
|
|
62
|
+
"afterEquals": false
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"match": {
|
|
66
|
+
"path": "(^|\\.)aws_s3_bucket_public_access_block\\..*\\.#action$"
|
|
67
|
+
},
|
|
68
|
+
"beforeIn": ["delete", "replace"]
|
|
69
|
+
}
|
|
70
|
+
],
|
|
71
|
+
"message": "S3 public access block is being turned off or removed. Leave all four block_public_* settings enabled unless the bucket is deliberately a public website."
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"id": "terraform-s3-public-acl",
|
|
75
|
+
"severity": "error",
|
|
76
|
+
"when": ["added", "changed"],
|
|
77
|
+
"match": {
|
|
78
|
+
"path": "(^|\\.)aws_s3_bucket(_acl)?\\..*\\.acl$"
|
|
79
|
+
},
|
|
80
|
+
"afterIn": ["public-read", "public-read-write", "authenticated-read"],
|
|
81
|
+
"messageTemplate": "S3 bucket ACL set to \"{after}\", which exposes objects outside the account. Use a bucket policy scoped to known principals instead."
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"id": "terraform-instance-size-changed",
|
|
85
|
+
"severity": "warn",
|
|
86
|
+
"when": ["changed"],
|
|
87
|
+
"match": {
|
|
88
|
+
"path": "(^|\\.)(aws_instance|aws_db_instance|aws_rds_cluster_instance|aws_launch_template|aws_launch_configuration|aws_elasticache_cluster|google_compute_instance|azurerm_linux_virtual_machine|azurerm_windows_virtual_machine)\\..*\\.(instance_type|instance_class|node_type|machine_type|size|vm_size)$"
|
|
89
|
+
},
|
|
90
|
+
"messageTemplate": "Instance size changes from {before} to {after}. Confirm the cost impact, and whether the change forces a restart or a replacement."
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"id": "terraform-capacity-jump",
|
|
94
|
+
"severity": "warn",
|
|
95
|
+
"when": ["changed"],
|
|
96
|
+
"match": {
|
|
97
|
+
"path": "\\.(desired_capacity|max_size|min_size|desired_count|node_count|initial_node_count|instance_count|number_of_nodes|max_capacity|min_capacity)$"
|
|
98
|
+
},
|
|
99
|
+
"numericJump": { "minMultiple": 2 },
|
|
100
|
+
"messageTemplate": "Capacity at {path} jumps from {before} to {after} (>=2x). Confirm the quota headroom and the cost of the new floor."
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"id": "terraform-sensitive-value-changed",
|
|
104
|
+
"severity": "warn",
|
|
105
|
+
"when": ["added", "changed", "removed"],
|
|
106
|
+
"anyOf": [
|
|
107
|
+
{ "afterEquals": "(sensitive value)" },
|
|
108
|
+
{ "beforeEquals": "(sensitive value)" }
|
|
109
|
+
],
|
|
110
|
+
"message": "A value Terraform marks sensitive is being set, changed, or removed. Flecto redacted it. Confirm it lives in a secret store and that anything depending on it is rotated."
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
"id": "terraform-hardcoded-credential",
|
|
114
|
+
"severity": "error",
|
|
115
|
+
"when": ["added", "changed"],
|
|
116
|
+
"afterLooksSecret": true,
|
|
117
|
+
"message": "A planned attribute value looks like a credential (known token format or high-entropy string) that Terraform did not mark sensitive. Move it to a secret store and rotate it."
|
|
118
|
+
}
|
|
119
|
+
]
|
|
120
|
+
}
|
package/src/parser.js
CHANGED
|
@@ -3,8 +3,10 @@ import { basename, extname } from 'path';
|
|
|
3
3
|
import yaml from 'js-yaml';
|
|
4
4
|
import TOML from '@iarna/toml';
|
|
5
5
|
import dotenv from 'dotenv';
|
|
6
|
+
import { isArmoredAgeFile, normalizeEncrypted, opaqueFileState } from './encrypted.js';
|
|
7
|
+
import { documentKeysOf, withDocumentKeys } from './documents.js';
|
|
6
8
|
|
|
7
|
-
const SUPPORTED_EXT = ['.json', '.yaml', '.yml', '.toml', '.env', '.ini'];
|
|
9
|
+
const SUPPORTED_EXT = ['.json', '.yaml', '.yml', '.toml', '.env', '.ini', '.age'];
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* True for dotenv-like names: `.env`, `.env.*`, `*.env`
|
|
@@ -65,11 +67,175 @@ export function parseIni(raw) {
|
|
|
65
67
|
}
|
|
66
68
|
|
|
67
69
|
function isPlainObject(v) {
|
|
68
|
-
|
|
70
|
+
if (v === null || typeof v !== 'object' || Array.isArray(v)) return false;
|
|
71
|
+
const prototype = Object.getPrototypeOf(v);
|
|
72
|
+
return prototype === Object.prototype || prototype === null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* What a YAML anchor that points back at one of its own containers collapses
|
|
77
|
+
* to. See {@link normalizeParsedValue} for why this is necessary and why a
|
|
78
|
+
* plain sentinel, rather than e.g. a path reference, is enough.
|
|
79
|
+
*/
|
|
80
|
+
export const CIRCULAR_SENTINEL = '<circular>';
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Convert parser-specific scalar objects into a stable JSON-safe tree before
|
|
84
|
+
* they reach snapshots, the differ, or output renderers.
|
|
85
|
+
*
|
|
86
|
+
* A recursive YAML anchor (`a: &x\n b: *x`) parses to a genuinely cyclic
|
|
87
|
+
* object — js-yaml resolves the alias to the *same* object reference, not a
|
|
88
|
+
* copy, so `a.b === a`. `ancestors` tracks the containers on the path
|
|
89
|
+
* currently being walked, the same ancestor-tracking pattern `collectLeaves`
|
|
90
|
+
* in policy.js uses for the same reason; revisiting one replaces the
|
|
91
|
+
* back-reference with {@link CIRCULAR_SENTINEL} instead of recursing into it.
|
|
92
|
+
* A value merely reached twice by separate branches (not an ancestor of
|
|
93
|
+
* itself) is still normalized in full on each branch.
|
|
94
|
+
*
|
|
95
|
+
* The result is always plain JSON: safe for `JSON.stringify` at the snapshot
|
|
96
|
+
* write, and safe for the differ, which never sees a cycle because nothing
|
|
97
|
+
* downstream of this function ever does. The sentinel is a fixed string
|
|
98
|
+
* rather than e.g. a back-reference path, so two files with the same cycle
|
|
99
|
+
* shape normalize to the same tree and compare equal — the whole point of a
|
|
100
|
+
* stable, readable diff path.
|
|
101
|
+
* @param {unknown} value
|
|
102
|
+
* @param {Set<object>} [ancestors] internal recursion state; omit when calling
|
|
103
|
+
* @returns {unknown}
|
|
104
|
+
*/
|
|
105
|
+
function normalizeParsedValue(value, ancestors = new Set()) {
|
|
106
|
+
if (typeof value === 'bigint') return String(value);
|
|
107
|
+
if (typeof value === 'number' && !Number.isFinite(value)) return String(value);
|
|
108
|
+
if (value instanceof Date) return value.toJSON();
|
|
109
|
+
if (Array.isArray(value)) {
|
|
110
|
+
if (ancestors.has(value)) return CIRCULAR_SENTINEL;
|
|
111
|
+
ancestors.add(value);
|
|
112
|
+
const out = value.map((item) => normalizeParsedValue(item, ancestors));
|
|
113
|
+
ancestors.delete(value);
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
if (
|
|
117
|
+
value !== null
|
|
118
|
+
&& typeof value === 'object'
|
|
119
|
+
&& typeof value.toJSON === 'function'
|
|
120
|
+
) {
|
|
121
|
+
const serialized = value.toJSON();
|
|
122
|
+
if (serialized !== value && (serialized === null || typeof serialized !== 'object')) {
|
|
123
|
+
return normalizeParsedValue(serialized, ancestors);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (isPlainObject(value)) {
|
|
127
|
+
if (ancestors.has(value)) return CIRCULAR_SENTINEL;
|
|
128
|
+
ancestors.add(value);
|
|
129
|
+
const out = Object.fromEntries(
|
|
130
|
+
Object.entries(value).map(([key, child]) => [key, normalizeParsedValue(child, ancestors)]),
|
|
131
|
+
);
|
|
132
|
+
ancestors.delete(value);
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Read a scalar field as a non-empty string, or null.
|
|
140
|
+
* @param {Record<string, unknown>} obj
|
|
141
|
+
* @param {string} key
|
|
142
|
+
* @returns {string | null}
|
|
143
|
+
*/
|
|
144
|
+
function scalarField(obj, key) {
|
|
145
|
+
const value = obj[key];
|
|
146
|
+
if (value == null || typeof value === 'object') return null;
|
|
147
|
+
const text = String(value).trim();
|
|
148
|
+
return text === '' ? null : text;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Stable identity for one document of a multi-document YAML file.
|
|
153
|
+
* Kubernetes-shaped documents key on `kind/name`, with the namespace inserted
|
|
154
|
+
* when present (`kind/namespace/name`). Anything else falls back to a top-level
|
|
155
|
+
* `id` then `name`, mirroring array identity matching in the differ.
|
|
156
|
+
* @param {unknown} doc
|
|
157
|
+
* @returns {string | null} null when the document has no stable identity
|
|
158
|
+
*/
|
|
159
|
+
function documentIdentity(doc) {
|
|
160
|
+
if (!isPlainObject(doc)) return null;
|
|
161
|
+
|
|
162
|
+
const kind = scalarField(doc, 'kind');
|
|
163
|
+
const metadata = isPlainObject(doc.metadata) ? doc.metadata : null;
|
|
164
|
+
const name = metadata ? scalarField(metadata, 'name') : null;
|
|
165
|
+
if (kind && name) {
|
|
166
|
+
const namespace = scalarField(metadata, 'namespace');
|
|
167
|
+
return namespace ? `${kind}/${namespace}/${name}` : `${kind}/${name}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return scalarField(doc, 'id') ?? scalarField(doc, 'name');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Keys for a multi-document file: identities when every document has a unique
|
|
175
|
+
* one, otherwise document indices. It is all-or-nothing so keys within one file
|
|
176
|
+
* stay homogeneous.
|
|
177
|
+
* @param {unknown[]} docs
|
|
178
|
+
* @returns {string[]}
|
|
179
|
+
*/
|
|
180
|
+
function documentKeys(docs) {
|
|
181
|
+
const identities = docs.map((doc) => {
|
|
182
|
+
const identity = documentIdentity(doc);
|
|
183
|
+
// "__proto__" as a key would mutate the prototype instead of adding an
|
|
184
|
+
// entry, silently losing the document. Fall back to indices instead.
|
|
185
|
+
return identity === '__proto__' ? null : identity;
|
|
186
|
+
});
|
|
187
|
+
const unique = new Set(identities);
|
|
188
|
+
if (!identities.includes(null) && unique.size === identities.length) {
|
|
189
|
+
return /** @type {string[]} */ (identities);
|
|
190
|
+
}
|
|
191
|
+
return docs.map((_, index) => String(index));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Parse a YAML stream, supporting `---`-separated multi-document files.
|
|
196
|
+
*
|
|
197
|
+
* A file holding a single document parses to that document unchanged, so diff
|
|
198
|
+
* paths for ordinary YAML are untouched. A file holding several documents
|
|
199
|
+
* parses to an object keyed per document, which lets the differ walk it like
|
|
200
|
+
* any other tree. Empty documents (a leading or trailing `---`, or a `null`
|
|
201
|
+
* document) are dropped, so a stray separator does not create a phantom entry.
|
|
202
|
+
*
|
|
203
|
+
* The keys it invents are recorded on the wrapper (see documents.js) so that
|
|
204
|
+
* everything downstream can tell a synthetic document prefix from a real
|
|
205
|
+
* configuration key without having to guess from its shape.
|
|
206
|
+
* @param {string} raw
|
|
207
|
+
* @returns {unknown}
|
|
208
|
+
* @throws {Error} on YAML syntax errors
|
|
209
|
+
*/
|
|
210
|
+
export function parseYamlStream(raw) {
|
|
211
|
+
const docs = yaml.loadAll(raw).filter((doc) => doc != null);
|
|
212
|
+
|
|
213
|
+
if (docs.length === 0) return withDocumentKeys({}, []);
|
|
214
|
+
if (docs.length === 1) return withDocumentKeys(docs[0], []);
|
|
215
|
+
|
|
216
|
+
const keys = documentKeys(docs);
|
|
217
|
+
/** @type {Record<string, unknown>} */
|
|
218
|
+
const out = {};
|
|
219
|
+
for (let i = 0; i < docs.length; i++) {
|
|
220
|
+
out[keys[i]] = docs[i];
|
|
221
|
+
}
|
|
222
|
+
return withDocumentKeys(out, keys);
|
|
69
223
|
}
|
|
70
224
|
|
|
71
225
|
/**
|
|
72
226
|
* Auto-detect the format of a file and parse it into a plain JS object.
|
|
227
|
+
*
|
|
228
|
+
* The parsed tree then goes through the encryption pass (see encrypted.js),
|
|
229
|
+
* which replaces every ciphertext-bearing value with an opaque sentinel. Doing
|
|
230
|
+
* it here rather than at render time is what makes the guarantee absolute:
|
|
231
|
+
* nothing downstream — diff, snapshot, webhook, report — is ever handed
|
|
232
|
+
* ciphertext, because the parser never produces any. A file with nothing to
|
|
233
|
+
* redact comes back as the very same object.
|
|
234
|
+
*
|
|
235
|
+
* The multi-document keys invented by `parseYamlStream` are handed to the
|
|
236
|
+
* encryption pass explicitly — a SOPS block sits inside each document, not at
|
|
237
|
+
* the root — and re-recorded on the returned tree, since normalization may have
|
|
238
|
+
* replaced the object the mark was on.
|
|
73
239
|
* @param {string} filepath
|
|
74
240
|
* @param {string} raw
|
|
75
241
|
* @returns {unknown}
|
|
@@ -87,26 +253,26 @@ export function parseContent(filepath, raw) {
|
|
|
87
253
|
`Supported extensions: ${supported}`
|
|
88
254
|
);
|
|
89
255
|
}
|
|
90
|
-
try {
|
|
91
|
-
if (envLike || ext === '.env') {
|
|
92
|
-
return dotenv.parse(raw);
|
|
93
|
-
}
|
|
94
256
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
if (ext === '.yaml' || ext === '.yml') {
|
|
104
|
-
const result = yaml.load(raw);
|
|
105
|
-
return result == null ? {} : result;
|
|
106
|
-
}
|
|
257
|
+
// A `.age` file, and any file that opens with the age armor header, is one
|
|
258
|
+
// opaque blob rather than a config document. There is no structure to parse
|
|
259
|
+
// and no parser that would not be guessing.
|
|
260
|
+
if (ext === '.age' || isArmoredAgeFile(raw)) {
|
|
261
|
+
return opaqueFileState(raw);
|
|
262
|
+
}
|
|
107
263
|
|
|
108
|
-
|
|
109
|
-
|
|
264
|
+
let parsed;
|
|
265
|
+
try {
|
|
266
|
+
if (envLike || ext === '.env') {
|
|
267
|
+
parsed = dotenv.parse(raw);
|
|
268
|
+
} else if (iniLike) {
|
|
269
|
+
parsed = parseIni(raw);
|
|
270
|
+
} else if (ext === '.json') {
|
|
271
|
+
parsed = JSON.parse(raw);
|
|
272
|
+
} else if (ext === '.yaml' || ext === '.yml') {
|
|
273
|
+
parsed = parseYamlStream(raw);
|
|
274
|
+
} else if (ext === '.toml') {
|
|
275
|
+
parsed = TOML.parse(raw);
|
|
110
276
|
}
|
|
111
277
|
} catch (err) {
|
|
112
278
|
const lineMatch = err.message?.match(/line (\d+)/i);
|
|
@@ -115,6 +281,9 @@ export function parseContent(filepath, raw) {
|
|
|
115
281
|
`Parse error in "${filepath}"${lineInfo}: ${err.message}`
|
|
116
282
|
);
|
|
117
283
|
}
|
|
284
|
+
|
|
285
|
+
const keys = documentKeysOf(parsed) ?? [];
|
|
286
|
+
return withDocumentKeys(normalizeEncrypted(normalizeParsedValue(parsed), keys), keys);
|
|
118
287
|
}
|
|
119
288
|
|
|
120
289
|
/**
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
import { join, resolve } from 'path';
|
|
3
|
+
|
|
4
|
+
import { diffTrees } from './differ.js';
|
|
5
|
+
import { parseFile } from './parser.js';
|
|
6
|
+
import { evaluatePolicies } from './policy.js';
|
|
7
|
+
|
|
8
|
+
const DEFAULT_CONFIG_NAME = 'flecto-policy-test.json';
|
|
9
|
+
|
|
10
|
+
function readJson(path, label) {
|
|
11
|
+
if (!existsSync(path)) {
|
|
12
|
+
throw new Error(`Policy fixture ${label} not found: ${path}`);
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
16
|
+
} catch (err) {
|
|
17
|
+
throw new Error(`Policy fixture ${label} is not valid JSON: ${path}: ${err.message}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function validateExpectedFinding(finding, index) {
|
|
22
|
+
if (!finding || typeof finding !== 'object') {
|
|
23
|
+
throw new Error(`Policy fixture expected[${index}] must be an object`);
|
|
24
|
+
}
|
|
25
|
+
for (const field of ['id', 'severity', 'path']) {
|
|
26
|
+
if (typeof finding[field] !== 'string' || !finding[field]) {
|
|
27
|
+
throw new Error(`Policy fixture expected[${index}].${field} must be a non-empty string`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function findingKey(finding) {
|
|
33
|
+
return `${finding.id}\u0000${finding.severity}\u0000${finding.path}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function displayFinding(finding) {
|
|
37
|
+
return `${finding.severity} ${finding.id} at ${finding.path}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Compare findings by id, severity, and path, ignoring messages and pack labels.
|
|
42
|
+
* @param {import('./policy.js').PolicyFinding[]} actual
|
|
43
|
+
* @param {Array<{id: string, severity: string, path: string}>} expected
|
|
44
|
+
*/
|
|
45
|
+
export function assertExpectedFindings(actual, expected) {
|
|
46
|
+
const expectedByKey = new Map();
|
|
47
|
+
const actualByKey = new Map();
|
|
48
|
+
for (const finding of expected) {
|
|
49
|
+
const key = findingKey(finding);
|
|
50
|
+
expectedByKey.set(key, (expectedByKey.get(key) ?? 0) + 1);
|
|
51
|
+
}
|
|
52
|
+
for (const finding of actual) {
|
|
53
|
+
const key = findingKey(finding);
|
|
54
|
+
actualByKey.set(key, (actualByKey.get(key) ?? 0) + 1);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const missing = [];
|
|
58
|
+
const unexpected = [];
|
|
59
|
+
for (const finding of expected) {
|
|
60
|
+
const key = findingKey(finding);
|
|
61
|
+
if ((actualByKey.get(key) ?? 0) > 0) {
|
|
62
|
+
actualByKey.set(key, actualByKey.get(key) - 1);
|
|
63
|
+
} else {
|
|
64
|
+
missing.push(finding);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
for (const finding of actual) {
|
|
68
|
+
const key = findingKey(finding);
|
|
69
|
+
if ((expectedByKey.get(key) ?? 0) > 0) {
|
|
70
|
+
expectedByKey.set(key, expectedByKey.get(key) - 1);
|
|
71
|
+
} else {
|
|
72
|
+
unexpected.push(finding);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (missing.length === 0 && unexpected.length === 0) return;
|
|
77
|
+
|
|
78
|
+
const lines = ['Policy fixture findings did not match.'];
|
|
79
|
+
if (missing.length > 0) {
|
|
80
|
+
lines.push('Missing findings:');
|
|
81
|
+
lines.push(...missing.map((finding) => ` - ${displayFinding(finding)}`));
|
|
82
|
+
}
|
|
83
|
+
if (unexpected.length > 0) {
|
|
84
|
+
lines.push('Unexpected findings:');
|
|
85
|
+
lines.push(...unexpected.map((finding) => ` - ${displayFinding(finding)}`));
|
|
86
|
+
}
|
|
87
|
+
throw new Error(lines.join('\n'));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Run a policy fixture stored in a directory.
|
|
92
|
+
* @param {string} fixtureDir
|
|
93
|
+
* @param {{ configName?: string }} [options]
|
|
94
|
+
*/
|
|
95
|
+
export async function testPolicyFixture(fixtureDir, options = {}) {
|
|
96
|
+
const dir = resolve(fixtureDir);
|
|
97
|
+
const configName = options.configName ?? DEFAULT_CONFIG_NAME;
|
|
98
|
+
const configPath = join(dir, configName);
|
|
99
|
+
const config = readJson(configPath, 'config');
|
|
100
|
+
if (!Array.isArray(config.expected)) {
|
|
101
|
+
throw new Error(`Policy fixture config must contain an expected array: ${configPath}`);
|
|
102
|
+
}
|
|
103
|
+
config.expected.forEach(validateExpectedFinding);
|
|
104
|
+
|
|
105
|
+
const baselinePath = resolve(dir, config.baseline ?? 'baseline.json');
|
|
106
|
+
const currentPath = resolve(dir, config.current ?? 'current.json');
|
|
107
|
+
const baseline = readJson(baselinePath, 'baseline');
|
|
108
|
+
if (!existsSync(currentPath)) {
|
|
109
|
+
throw new Error(`Policy fixture current file not found: ${currentPath}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const changes = diffTrees(baseline.state ?? baseline, parseFile(currentPath));
|
|
113
|
+
const findings = await evaluatePolicies(changes, {
|
|
114
|
+
cwd: dir,
|
|
115
|
+
file: currentPath,
|
|
116
|
+
profile: config.profile ?? null,
|
|
117
|
+
source: config.source ?? 'ci',
|
|
118
|
+
policies: config.policies,
|
|
119
|
+
plugins: config.plugins,
|
|
120
|
+
});
|
|
121
|
+
assertExpectedFindings(findings, config.expected);
|
|
122
|
+
|
|
123
|
+
return { fixtureDir: dir, changes, findings };
|
|
124
|
+
}
|