flecto 1.0.2 → 2.1.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 +103 -0
- package/README.md +297 -109
- package/index.js +362 -35
- package/package.json +8 -6
- package/schemas/flecto-envelope-2.0.json +65 -0
- package/schemas/flecto-policy-pack-2.0.json +124 -0
- package/src/alerter.js +11 -10
- package/src/config.js +59 -2
- package/src/differ.js +153 -16
- package/src/envelope.js +6 -4
- package/src/packs/compose.json +45 -0
- package/src/packs/default.json +37 -0
- package/src/packs/node-runtime.json +44 -0
- package/src/packs/strict-prod.json +37 -0
- package/src/parser.js +80 -14
- package/src/policy-test.js +124 -0
- package/src/policy.js +541 -34
- package/src/renderer.js +62 -13
- package/src/watcher.js +28 -11
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "node-runtime",
|
|
3
|
+
"rules": [
|
|
4
|
+
{
|
|
5
|
+
"id": "node-runtime-engine-removed",
|
|
6
|
+
"severity": "warn",
|
|
7
|
+
"when": ["removed"],
|
|
8
|
+
"match": {
|
|
9
|
+
"pathEquals": "engines.node"
|
|
10
|
+
},
|
|
11
|
+
"message": "Node.js engine requirement was removed. Keep a supported runtime floor to avoid accidental runtime downgrades."
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"id": "node-runtime-tls-verification-disabled",
|
|
15
|
+
"severity": "error",
|
|
16
|
+
"when": ["added", "changed"],
|
|
17
|
+
"match": {
|
|
18
|
+
"path": "(^|\\.)NODE_TLS_REJECT_UNAUTHORIZED$"
|
|
19
|
+
},
|
|
20
|
+
"afterIn": [0, "0"],
|
|
21
|
+
"message": "NODE_TLS_REJECT_UNAUTHORIZED disables TLS certificate verification. Remove it and fix the certificate chain."
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"id": "node-runtime-debug-enabled",
|
|
25
|
+
"severity": "warn",
|
|
26
|
+
"when": ["added", "changed"],
|
|
27
|
+
"match": {
|
|
28
|
+
"path": "(^|\\.)NODE_DEBUG$"
|
|
29
|
+
},
|
|
30
|
+
"afterMatches": ".+",
|
|
31
|
+
"message": "NODE_DEBUG is enabled. Confirm verbose runtime debugging is appropriate for this environment."
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"id": "node-runtime-inspector-enabled",
|
|
35
|
+
"severity": "warn",
|
|
36
|
+
"when": ["added", "changed"],
|
|
37
|
+
"match": {
|
|
38
|
+
"path": "(^|\\.)NODE_OPTIONS$"
|
|
39
|
+
},
|
|
40
|
+
"afterMatches": "(^|\\s)--inspect(?:-brk)?(?:=|\\s|$)",
|
|
41
|
+
"message": "Node.js inspector is enabled through NODE_OPTIONS. Avoid exposing debug ports outside trusted development environments."
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "strict-prod",
|
|
3
|
+
"rules": [
|
|
4
|
+
{
|
|
5
|
+
"id": "secret-key-changed",
|
|
6
|
+
"severity": "error",
|
|
7
|
+
"when": ["added", "changed", "removed"],
|
|
8
|
+
"match": {
|
|
9
|
+
"path": "(secret|token|password|api[_-]?key|private[_-]?key|credential)",
|
|
10
|
+
"pathFlags": "i"
|
|
11
|
+
},
|
|
12
|
+
"message": "Sensitive-looking key changed in production profile. Confirm rotation and access controls."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"id": "dangerous-toggle-enabled",
|
|
16
|
+
"severity": "error",
|
|
17
|
+
"when": ["added", "changed"],
|
|
18
|
+
"match": {
|
|
19
|
+
"path": "(debug|allow_insecure|disable_tls|skip_tls_verify|permit_all)",
|
|
20
|
+
"pathFlags": "i"
|
|
21
|
+
},
|
|
22
|
+
"afterTruthy": true,
|
|
23
|
+
"message": "Dangerous toggle enabled in production profile."
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"id": "pool-size-jump",
|
|
27
|
+
"severity": "error",
|
|
28
|
+
"when": ["changed"],
|
|
29
|
+
"match": {
|
|
30
|
+
"path": "pool_size$",
|
|
31
|
+
"pathFlags": "i"
|
|
32
|
+
},
|
|
33
|
+
"numericJump": { "minMultiple": 2 },
|
|
34
|
+
"messageTemplate": "Pool size increased from {before} to {after} (>=2x) in production."
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
}
|
package/src/parser.js
CHANGED
|
@@ -1,10 +1,72 @@
|
|
|
1
1
|
import { readFileSync } from 'fs';
|
|
2
|
-
import { extname } from 'path';
|
|
2
|
+
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
6
|
|
|
7
|
-
const
|
|
7
|
+
const SUPPORTED_EXT = ['.json', '.yaml', '.yml', '.toml', '.env', '.ini'];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* True for dotenv-like names: `.env`, `.env.*`, `*.env`
|
|
11
|
+
* @param {string} filepath
|
|
12
|
+
*/
|
|
13
|
+
export function isEnvFilename(filepath) {
|
|
14
|
+
const base = basename(filepath);
|
|
15
|
+
return base === '.env' || base.startsWith('.env.') || base.endsWith('.env');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* True for INI files.
|
|
20
|
+
* @param {string} filepath
|
|
21
|
+
*/
|
|
22
|
+
export function isIniFilename(filepath) {
|
|
23
|
+
return extname(filepath).toLowerCase() === '.ini';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Minimal INI parser: [section] + key=value.
|
|
28
|
+
* Root keys are top-level; sectioned keys nest under the section name.
|
|
29
|
+
* @param {string} raw
|
|
30
|
+
* @returns {Record<string, unknown>}
|
|
31
|
+
*/
|
|
32
|
+
export function parseIni(raw) {
|
|
33
|
+
/** @type {Record<string, unknown>} */
|
|
34
|
+
const out = {};
|
|
35
|
+
let section = null;
|
|
36
|
+
|
|
37
|
+
for (const line of String(raw).split(/\r?\n/)) {
|
|
38
|
+
const trimmed = line.trim();
|
|
39
|
+
if (!trimmed || trimmed.startsWith(';') || trimmed.startsWith('#')) continue;
|
|
40
|
+
const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/);
|
|
41
|
+
if (sectionMatch) {
|
|
42
|
+
section = sectionMatch[1].trim();
|
|
43
|
+
if (!isPlainObject(out[section])) out[section] = {};
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const eq = trimmed.indexOf('=');
|
|
47
|
+
if (eq === -1) continue;
|
|
48
|
+
const key = trimmed.slice(0, eq).trim();
|
|
49
|
+
let value = trimmed.slice(eq + 1).trim();
|
|
50
|
+
if (
|
|
51
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
52
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
53
|
+
) {
|
|
54
|
+
value = value.slice(1, -1);
|
|
55
|
+
}
|
|
56
|
+
if (section == null) {
|
|
57
|
+
out[key] = value;
|
|
58
|
+
} else {
|
|
59
|
+
/** @type {Record<string, string>} */
|
|
60
|
+
const bucket = /** @type {any} */ (out[section]);
|
|
61
|
+
bucket[key] = value;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isPlainObject(v) {
|
|
68
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
69
|
+
}
|
|
8
70
|
|
|
9
71
|
/**
|
|
10
72
|
* Auto-detect the format of a file and parse it into a plain JS object.
|
|
@@ -15,35 +77,38 @@ const SUPPORTED = ['.json', '.yaml', '.yml', '.toml', '.env'];
|
|
|
15
77
|
*/
|
|
16
78
|
export function parseContent(filepath, raw) {
|
|
17
79
|
const ext = extname(filepath).toLowerCase();
|
|
80
|
+
const envLike = isEnvFilename(filepath);
|
|
81
|
+
const iniLike = isIniFilename(filepath);
|
|
18
82
|
|
|
19
|
-
if (!
|
|
20
|
-
const supported =
|
|
83
|
+
if (!envLike && !iniLike && !SUPPORTED_EXT.includes(ext)) {
|
|
84
|
+
const supported = [...SUPPORTED_EXT, '.env.*', '*.env'].join(', ');
|
|
21
85
|
throw new Error(
|
|
22
|
-
`Unsupported file format "${ext}" for "${filepath}".\n` +
|
|
86
|
+
`Unsupported file format "${ext || '(none)'}" for "${filepath}".\n` +
|
|
23
87
|
`Supported extensions: ${supported}`
|
|
24
88
|
);
|
|
25
89
|
}
|
|
26
90
|
try {
|
|
91
|
+
if (envLike || ext === '.env') {
|
|
92
|
+
return dotenv.parse(raw);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (iniLike) {
|
|
96
|
+
return parseIni(raw);
|
|
97
|
+
}
|
|
98
|
+
|
|
27
99
|
if (ext === '.json') {
|
|
28
100
|
return JSON.parse(raw);
|
|
29
101
|
}
|
|
30
102
|
|
|
31
103
|
if (ext === '.yaml' || ext === '.yml') {
|
|
32
104
|
const result = yaml.load(raw);
|
|
33
|
-
// yaml.load can return null for empty files
|
|
34
105
|
return result == null ? {} : result;
|
|
35
106
|
}
|
|
36
107
|
|
|
37
108
|
if (ext === '.toml') {
|
|
38
109
|
return TOML.parse(raw);
|
|
39
110
|
}
|
|
40
|
-
|
|
41
|
-
if (ext === '.env') {
|
|
42
|
-
const parsed = dotenv.parse(raw);
|
|
43
|
-
return parsed;
|
|
44
|
-
}
|
|
45
111
|
} catch (err) {
|
|
46
|
-
// Try to extract line info from error messages
|
|
47
112
|
const lineMatch = err.message?.match(/line (\d+)/i);
|
|
48
113
|
const lineInfo = lineMatch ? ` (line ${lineMatch[1]})` : '';
|
|
49
114
|
throw new Error(
|
|
@@ -68,10 +133,11 @@ export function parseFile(filepath) {
|
|
|
68
133
|
}
|
|
69
134
|
|
|
70
135
|
/**
|
|
71
|
-
* Returns true if the file
|
|
136
|
+
* Returns true if the file format is supported.
|
|
72
137
|
* @param {string} filepath
|
|
73
138
|
* @returns {boolean}
|
|
74
139
|
*/
|
|
75
140
|
export function isSupported(filepath) {
|
|
76
|
-
|
|
141
|
+
if (isEnvFilename(filepath) || isIniFilename(filepath)) return true;
|
|
142
|
+
return SUPPORTED_EXT.includes(extname(filepath).toLowerCase());
|
|
77
143
|
}
|
|
@@ -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
|
+
}
|