@dockerforge/core 0.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/LICENSE +201 -0
- package/NOTICE +21 -0
- package/README.md +88 -0
- package/package.json +44 -0
- package/src/_engine/backend/src/modules/analysis/analyser.js +1128 -0
- package/src/_engine/backend/src/modules/engine.js +219 -0
- package/src/_engine/backend/src/modules/explanation/explainer.js +78 -0
- package/src/_engine/backend/src/modules/generation/composeGenerator.js +318 -0
- package/src/_engine/backend/src/modules/generation/generator.js +1355 -0
- package/src/_engine/backend/src/modules/ingestion/ingestion.js +525 -0
- package/src/_engine/backend/src/modules/optimisation/optimiser.js +38 -0
- package/src/_engine/backend/src/modules/security/security.js +91 -0
- package/src/_engine/shared/constants.js +98 -0
- package/src/errors.js +36 -0
- package/src/index.js +70 -0
- package/src/lint/index.js +92 -0
- package/src/lint/parse.js +112 -0
- package/src/lint/rules.js +148 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// shared/constants.js
|
|
2
|
+
// Shared between frontend and backend
|
|
3
|
+
|
|
4
|
+
const STACKS = {
|
|
5
|
+
NODE: 'node',
|
|
6
|
+
PYTHON: 'python',
|
|
7
|
+
DOTNET: 'dotnet',
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const DETECTION_FILES = {
|
|
11
|
+
[STACKS.NODE]: ['package.json'],
|
|
12
|
+
[STACKS.PYTHON]: ['requirements.txt', 'pyproject.toml', 'setup.py', 'Pipfile'],
|
|
13
|
+
[STACKS.DOTNET]: ['.csproj'], // matched by extension
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const BASE_IMAGES = {
|
|
17
|
+
[STACKS.NODE]: (version) => `node:${version}-alpine3.21`,
|
|
18
|
+
[STACKS.PYTHON]: (version) => `python:${version}-slim`,
|
|
19
|
+
[STACKS.DOTNET]: {
|
|
20
|
+
runtime: (version) => `mcr.microsoft.com/dotnet/aspnet:${version}`,
|
|
21
|
+
sdk: (version) => `mcr.microsoft.com/dotnet/sdk:${version}`,
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const DEFAULT_VERSIONS = {
|
|
26
|
+
[STACKS.NODE]: '20',
|
|
27
|
+
[STACKS.PYTHON]: '3.12',
|
|
28
|
+
[STACKS.DOTNET]: '8.0',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const DEFAULT_PORTS = {
|
|
32
|
+
[STACKS.NODE]: 3000,
|
|
33
|
+
[STACKS.PYTHON]: 8000,
|
|
34
|
+
[STACKS.DOTNET]: 8080,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const IGNORED_DIRS = [
|
|
38
|
+
'.git',
|
|
39
|
+
'node_modules',
|
|
40
|
+
'__pycache__',
|
|
41
|
+
'.pytest_cache',
|
|
42
|
+
'bin',
|
|
43
|
+
'obj',
|
|
44
|
+
'dist',
|
|
45
|
+
'build',
|
|
46
|
+
'.next',
|
|
47
|
+
'.nuxt',
|
|
48
|
+
'coverage',
|
|
49
|
+
'.venv',
|
|
50
|
+
'venv',
|
|
51
|
+
'env',
|
|
52
|
+
// test infra
|
|
53
|
+
'__tests__',
|
|
54
|
+
'__mocks__',
|
|
55
|
+
'test',
|
|
56
|
+
'tests',
|
|
57
|
+
'fixtures',
|
|
58
|
+
'spec',
|
|
59
|
+
'specs',
|
|
60
|
+
// sample / docs — not deployable services
|
|
61
|
+
'examples',
|
|
62
|
+
'example',
|
|
63
|
+
'samples',
|
|
64
|
+
'sample',
|
|
65
|
+
'demos',
|
|
66
|
+
'demo',
|
|
67
|
+
'docs',
|
|
68
|
+
'doc',
|
|
69
|
+
'dev-docs',
|
|
70
|
+
'documentation',
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
// Root-level config files that affect compiled output and must be COPYed into build stages.
|
|
74
|
+
// Dev-only tools (eslint, prettier, stylelint, editorconfig) are deliberately excluded —
|
|
75
|
+
// they don't affect npm run build output and are typically in .dockerignore.
|
|
76
|
+
const ROOT_CONFIG_FILES = [
|
|
77
|
+
// TypeScript compilation
|
|
78
|
+
'tsconfig.json', 'tsconfig.base.json', 'tsconfig.build.json', 'tsconfig.app.json',
|
|
79
|
+
// Babel
|
|
80
|
+
'babel.config.js', 'babel.config.cjs', 'babel.config.mjs', 'babel.config.json',
|
|
81
|
+
'.babelrc', '.babelrc.js', '.babelrc.json',
|
|
82
|
+
// PostCSS (affects CSS output)
|
|
83
|
+
'postcss.config.js', 'postcss.config.cjs', 'postcss.config.mjs',
|
|
84
|
+
// ESLint — react-scripts/Next.js run ESLint during `npm run build`
|
|
85
|
+
'.eslintrc', '.eslintrc.js', '.eslintrc.cjs', '.eslintrc.mjs',
|
|
86
|
+
'.eslintrc.json', '.eslintrc.yml', '.eslintrc.yaml',
|
|
87
|
+
'eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs',
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
module.exports = {
|
|
91
|
+
STACKS,
|
|
92
|
+
DETECTION_FILES,
|
|
93
|
+
BASE_IMAGES,
|
|
94
|
+
DEFAULT_VERSIONS,
|
|
95
|
+
DEFAULT_PORTS,
|
|
96
|
+
IGNORED_DIRS,
|
|
97
|
+
ROOT_CONFIG_FILES,
|
|
98
|
+
};
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Typed error model for @dockerforge/core (see docs/contracts/core-contract.md, Section 4).
|
|
4
|
+
// Core throws these, never a bare Error, so callers can branch on `.code` reliably.
|
|
5
|
+
|
|
6
|
+
class DockerForgeError extends Error {
|
|
7
|
+
constructor(message, code) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = this.constructor.name;
|
|
10
|
+
this.code = code;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
class PathNotFoundError extends DockerForgeError {
|
|
15
|
+
constructor(message) { super(message, 'PATH_NOT_FOUND'); }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
class NotADirectoryError extends DockerForgeError {
|
|
19
|
+
constructor(message) { super(message, 'NOT_A_DIRECTORY'); }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class UnsupportedStackError extends DockerForgeError {
|
|
23
|
+
constructor(message) { super(message, 'UNSUPPORTED_STACK'); }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
class IngestError extends DockerForgeError {
|
|
27
|
+
constructor(message) { super(message, 'INGEST_ERROR'); }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = {
|
|
31
|
+
DockerForgeError,
|
|
32
|
+
PathNotFoundError,
|
|
33
|
+
NotADirectoryError,
|
|
34
|
+
UnsupportedStackError,
|
|
35
|
+
IngestError,
|
|
36
|
+
};
|
package/src/index.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// @dockerforge/core - thin facade (Chunk 0.3).
|
|
4
|
+
//
|
|
5
|
+
// This re-exports the CURRENT engine in backend/src/modules/* UNCHANGED. We do not move
|
|
6
|
+
// internals yet (that happens gradually, file-by-file, behind the existing ~600 tests).
|
|
7
|
+
// What this file fixes is the PUBLIC SURFACE: it is exactly the contract in
|
|
8
|
+
// docs/contracts/core-contract.md. The CLI and the Cloud API code against this, not against
|
|
9
|
+
// backend/src/modules paths.
|
|
10
|
+
//
|
|
11
|
+
// No-network guarantee: this module performs zero outbound network calls. It only reads the
|
|
12
|
+
// local filesystem under the resolved project path. Remote ingestion (git URL / zip URL) is
|
|
13
|
+
// intentionally NOT exposed here - that adapter lives on the proprietary cloud side.
|
|
14
|
+
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const fs = require('fs-extra');
|
|
17
|
+
|
|
18
|
+
// Engine resolution: a published tarball vendors the engine under ./_engine (see
|
|
19
|
+
// scripts/vendor-engine.js, run at prepack). In the monorepo (dev/test) that dir does not
|
|
20
|
+
// exist, so we fall back to the canonical backend/ source. Same code either way.
|
|
21
|
+
const _vendoredEngine = require('path').join(__dirname, '_engine', 'backend', 'src', 'modules', 'engine.js');
|
|
22
|
+
const engine = require('fs').existsSync(_vendoredEngine)
|
|
23
|
+
? require(_vendoredEngine)
|
|
24
|
+
: require('../../../backend/src/modules/engine');
|
|
25
|
+
const errors = require('./errors');
|
|
26
|
+
const { lint } = require('./lint');
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Validate a local directory and return its resolved absolute path.
|
|
30
|
+
* Throws typed errors (PathNotFoundError / NotADirectoryError) per the contract.
|
|
31
|
+
* @param {string} targetPath
|
|
32
|
+
* @returns {Promise<string>}
|
|
33
|
+
*/
|
|
34
|
+
async function ingestLocal(targetPath) {
|
|
35
|
+
const resolved = path.resolve(targetPath);
|
|
36
|
+
let stat;
|
|
37
|
+
try {
|
|
38
|
+
stat = await fs.stat(resolved);
|
|
39
|
+
} catch {
|
|
40
|
+
throw new errors.PathNotFoundError(`Path not found: ${resolved}`);
|
|
41
|
+
}
|
|
42
|
+
if (!stat.isDirectory()) {
|
|
43
|
+
throw new errors.NotADirectoryError(`Not a directory: ${resolved}`);
|
|
44
|
+
}
|
|
45
|
+
return resolved;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Run the offline engine pipeline. Only the documented OFFLINE input fields are accepted;
|
|
50
|
+
* remote-ingest fields (gitUrl/zipPath/fileTree/workDir/pat) are deliberately not forwarded.
|
|
51
|
+
* @param {{projectPath: string, hints?: object, optimise?: boolean, security?: boolean, validation?: object|null}} input
|
|
52
|
+
* @returns {Promise<object>} EngineResult (see contract Section 2.2)
|
|
53
|
+
*/
|
|
54
|
+
async function runDockerfileEngine(input = {}) {
|
|
55
|
+
const { projectPath, hints, optimise, security, validation } = input;
|
|
56
|
+
if (!projectPath) {
|
|
57
|
+
throw new errors.IngestError(
|
|
58
|
+
'projectPath is required: @dockerforge/core is offline. Remote ingestion (git URL/zip) lives in the cloud adapter, not core.'
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return engine.runDockerfileEngine({ projectPath, hints, optimise, security, validation });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = {
|
|
65
|
+
ingestLocal,
|
|
66
|
+
runDockerfileEngine,
|
|
67
|
+
lint,
|
|
68
|
+
// typed errors, re-exported so consumers import them from the package root
|
|
69
|
+
...errors,
|
|
70
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// @dockerforge/core lint engine (Chunk 1.2). Offline, pure-ish (reads local files only).
|
|
4
|
+
// Returns a LintResult per the contract (Section 2.3). Serialisation (SARIF/JSON/human) is
|
|
5
|
+
// the CLI's job, not core's.
|
|
6
|
+
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const fs = require('fs/promises');
|
|
9
|
+
|
|
10
|
+
const { parseDockerfile, toStages, collectAliases } = require('./parse');
|
|
11
|
+
const { RULES } = require('./rules');
|
|
12
|
+
const errors = require('../errors');
|
|
13
|
+
|
|
14
|
+
const SEVERITY_ORDER = ['info', 'low', 'medium', 'high', 'critical'];
|
|
15
|
+
|
|
16
|
+
async function readIfExists(p) {
|
|
17
|
+
try { return await fs.readFile(p, 'utf8'); } catch { return null; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Resolve the lint target into { dockerfileText, fsContext }.
|
|
21
|
+
// target: string (path to a Dockerfile or a project dir) OR { dockerfile: string }.
|
|
22
|
+
async function resolveTarget(target) {
|
|
23
|
+
if (target && typeof target === 'object' && typeof target.dockerfile === 'string') {
|
|
24
|
+
return { dockerfileText: target.dockerfile, fsContext: { hasFsContext: false, dockerignore: null } };
|
|
25
|
+
}
|
|
26
|
+
if (typeof target !== 'string') {
|
|
27
|
+
throw new errors.IngestError('lint target must be a path string or { dockerfile } object');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const resolved = path.resolve(target);
|
|
31
|
+
let stat;
|
|
32
|
+
try { stat = await fs.stat(resolved); }
|
|
33
|
+
catch { throw new errors.PathNotFoundError(`Path not found: ${resolved}`); }
|
|
34
|
+
|
|
35
|
+
let dockerfilePath;
|
|
36
|
+
let dir;
|
|
37
|
+
if (stat.isDirectory()) {
|
|
38
|
+
dir = resolved;
|
|
39
|
+
dockerfilePath = path.join(resolved, 'Dockerfile');
|
|
40
|
+
} else {
|
|
41
|
+
dir = path.dirname(resolved);
|
|
42
|
+
dockerfilePath = resolved;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const dockerfileText = await readIfExists(dockerfilePath);
|
|
46
|
+
if (dockerfileText === null) {
|
|
47
|
+
throw new errors.PathNotFoundError(`Dockerfile not found: ${dockerfilePath}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const diContent = await readIfExists(path.join(dir, '.dockerignore'));
|
|
51
|
+
return {
|
|
52
|
+
dockerfileText,
|
|
53
|
+
fsContext: {
|
|
54
|
+
hasFsContext: true,
|
|
55
|
+
dockerignore: diContent === null ? { exists: false, content: '' } : { exists: true, content: diContent },
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Lint a Dockerfile.
|
|
62
|
+
* @param {string|{dockerfile:string}} target
|
|
63
|
+
* @param {{failOn?:string, rules?:string[]}} [options]
|
|
64
|
+
* @returns {Promise<{findings:Array, summary:{counts:object, worst:string|null}}>}
|
|
65
|
+
*/
|
|
66
|
+
async function lint(target, options = {}) {
|
|
67
|
+
const { dockerfileText, fsContext } = await resolveTarget(target);
|
|
68
|
+
|
|
69
|
+
const instructions = parseDockerfile(dockerfileText);
|
|
70
|
+
const stages = toStages(instructions);
|
|
71
|
+
const aliases = collectAliases(stages);
|
|
72
|
+
const ctx = { instructions, stages, aliases, ...fsContext };
|
|
73
|
+
|
|
74
|
+
const enabled = options.rules && options.rules.length ? new Set(options.rules) : null;
|
|
75
|
+
|
|
76
|
+
let findings = [];
|
|
77
|
+
for (const rule of RULES) {
|
|
78
|
+
if (enabled && !enabled.has(rule.id)) continue;
|
|
79
|
+
findings = findings.concat(rule.check(ctx));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
findings.sort((a, b) => (a.line || 0) - (b.line || 0) || a.ruleId.localeCompare(b.ruleId));
|
|
83
|
+
|
|
84
|
+
const counts = { info: 0, low: 0, medium: 0, high: 0, critical: 0 };
|
|
85
|
+
for (const f of findings) counts[f.severity]++;
|
|
86
|
+
let worst = null;
|
|
87
|
+
for (const s of SEVERITY_ORDER) if (counts[s] > 0) worst = s;
|
|
88
|
+
|
|
89
|
+
return { findings, summary: { counts, worst } };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = { lint, SEVERITY_ORDER };
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Minimal, deterministic Dockerfile parser for the lint engine (Chunk 1.2).
|
|
4
|
+
// Not a full Dockerfile grammar - just enough to evaluate the 6 cheap rules reliably.
|
|
5
|
+
// Handles comments, blank lines, and backslash line-continuations, tracking line numbers.
|
|
6
|
+
|
|
7
|
+
function parseDockerfile(text) {
|
|
8
|
+
const rawLines = String(text).split(/\r?\n/);
|
|
9
|
+
const instructions = [];
|
|
10
|
+
let i = 0;
|
|
11
|
+
|
|
12
|
+
while (i < rawLines.length) {
|
|
13
|
+
const startLine = i + 1;
|
|
14
|
+
const trimmed = rawLines[i].trim();
|
|
15
|
+
|
|
16
|
+
if (trimmed === '' || trimmed.startsWith('#')) { i++; continue; }
|
|
17
|
+
|
|
18
|
+
// Join continuation lines (trailing backslash).
|
|
19
|
+
let content = rawLines[i];
|
|
20
|
+
while (/\\\s*$/.test(content) && i + 1 < rawLines.length) {
|
|
21
|
+
content = content.replace(/\\\s*$/, ' ');
|
|
22
|
+
i++;
|
|
23
|
+
content += rawLines[i];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const m = content.trim().match(/^(\w+)\s*([\s\S]*)$/);
|
|
27
|
+
if (m) {
|
|
28
|
+
instructions.push({
|
|
29
|
+
instruction: m[1].toUpperCase(),
|
|
30
|
+
args: (m[2] || '').trim(),
|
|
31
|
+
line: startLine,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
i++;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return instructions;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Split a flat instruction list into stages (one per FROM). Instructions before the
|
|
41
|
+
// first FROM (rare; e.g. top-level ARG) are ignored for stage-scoped rules.
|
|
42
|
+
function toStages(instructions) {
|
|
43
|
+
const stages = [];
|
|
44
|
+
let current = null;
|
|
45
|
+
for (const ins of instructions) {
|
|
46
|
+
if (ins.instruction === 'FROM') {
|
|
47
|
+
current = { from: ins, instructions: [] };
|
|
48
|
+
stages.push(current);
|
|
49
|
+
} else if (current) {
|
|
50
|
+
current.instructions.push(ins);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return stages;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Collect lowercased stage aliases (FROM x AS <alias>) so base-image rules can skip
|
|
57
|
+
// "FROM <previous-stage>" references.
|
|
58
|
+
function collectAliases(stages) {
|
|
59
|
+
const aliases = new Set();
|
|
60
|
+
for (const stage of stages) {
|
|
61
|
+
const m = stage.from.args.match(/\s+AS\s+(\S+)\s*$/i);
|
|
62
|
+
if (m) aliases.add(m[1].toLowerCase());
|
|
63
|
+
}
|
|
64
|
+
return aliases;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Parse ENV pairs: supports "ENV K=V K2=V2" (quoted values) and legacy "ENV K V...".
|
|
68
|
+
function parseEnvPairs(args) {
|
|
69
|
+
const t = args.trim();
|
|
70
|
+
if (t.includes('=')) {
|
|
71
|
+
const pairs = [];
|
|
72
|
+
const re = /([A-Za-z_][\w.-]*)=("([^"]*)"|'([^']*)'|\S+)/g;
|
|
73
|
+
let m;
|
|
74
|
+
while ((m = re.exec(t))) {
|
|
75
|
+
const value = m[3] !== undefined ? m[3] : (m[4] !== undefined ? m[4] : m[2]);
|
|
76
|
+
pairs.push({ key: m[1], value });
|
|
77
|
+
}
|
|
78
|
+
return pairs;
|
|
79
|
+
}
|
|
80
|
+
const sp = t.split(/\s+/);
|
|
81
|
+
const key = sp.shift();
|
|
82
|
+
return key ? [{ key, value: sp.join(' ') }] : [];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Parse "ARG KEY[=DEFAULT]".
|
|
86
|
+
function parseArg(args) {
|
|
87
|
+
const t = args.trim();
|
|
88
|
+
const eq = t.indexOf('=');
|
|
89
|
+
if (eq === -1) return { key: t.split(/\s+/)[0] || '', value: null };
|
|
90
|
+
return {
|
|
91
|
+
key: t.slice(0, eq).trim(),
|
|
92
|
+
value: t.slice(eq + 1).trim().replace(/^["']|["']$/g, ''),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Strip COPY/ADD flags (--from=, --chown=, --chmod=, --link) and return the remaining tokens.
|
|
97
|
+
function instructionTokens(args) {
|
|
98
|
+
return args
|
|
99
|
+
.replace(/--[\w-]+(=\S+)?/g, '')
|
|
100
|
+
.trim()
|
|
101
|
+
.split(/\s+/)
|
|
102
|
+
.filter(Boolean);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = {
|
|
106
|
+
parseDockerfile,
|
|
107
|
+
toStages,
|
|
108
|
+
collectAliases,
|
|
109
|
+
parseEnvPairs,
|
|
110
|
+
parseArg,
|
|
111
|
+
instructionTokens,
|
|
112
|
+
};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The first-wave lint rules (Chunk 1.2): cheap, deterministic, low false-positive.
|
|
4
|
+
// Each rule: { id, title, check(ctx) -> Finding[] }. A Finding is
|
|
5
|
+
// { ruleId, severity, message, fixHint, line|null }.
|
|
6
|
+
// Second-wave rules (EOL base, multi-stage detection, apt cache, ADD-vs-COPY) come later.
|
|
7
|
+
|
|
8
|
+
const { parseEnvPairs, parseArg, instructionTokens } = require('./parse');
|
|
9
|
+
|
|
10
|
+
const SECRET_KEY = /(passwd|password|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|credential|auth_?key)/i;
|
|
11
|
+
const ENV_EXCLUDE = ['.env', '.env*', '*.env', '.env.*', '**/.env'];
|
|
12
|
+
|
|
13
|
+
function finding(ruleId, severity, message, fixHint, line) {
|
|
14
|
+
return { ruleId, severity, message, fixHint, line: line ?? null };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// DF001 - base image not pinned (:latest or no tag).
|
|
18
|
+
const unpinnedBase = {
|
|
19
|
+
id: 'DF001',
|
|
20
|
+
title: 'Base image is not pinned',
|
|
21
|
+
check(ctx) {
|
|
22
|
+
const out = [];
|
|
23
|
+
for (const stage of ctx.stages) {
|
|
24
|
+
let ref = stage.from.args.replace(/\s+AS\s+\S+\s*$/i, '').replace(/--platform=\S+\s*/i, '').trim();
|
|
25
|
+
const base = ref.toLowerCase();
|
|
26
|
+
if (!ref || base === 'scratch') continue;
|
|
27
|
+
if (ctx.aliases.has(base)) continue; // FROM <previous stage>
|
|
28
|
+
if (ref.includes('@sha256:')) continue; // digest-pinned
|
|
29
|
+
const lastSeg = ref.split('/').pop(); // avoids registry:port false-positive
|
|
30
|
+
const tag = lastSeg.includes(':') ? lastSeg.split(':').pop() : null;
|
|
31
|
+
if (!tag) {
|
|
32
|
+
out.push(finding('DF001', 'high', `Base image "${ref}" has no tag (implies :latest).`,
|
|
33
|
+
'Pin to an explicit version, ideally a digest (e.g. node:20-alpine@sha256:...).', stage.from.line));
|
|
34
|
+
} else if (tag === 'latest') {
|
|
35
|
+
out.push(finding('DF001', 'high', `Base image "${ref}" uses the :latest tag.`,
|
|
36
|
+
'Pin to an explicit version, ideally a digest, so builds are reproducible.', stage.from.line));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// DF002 - final stage runs as root (no non-root USER).
|
|
44
|
+
const rootUser = {
|
|
45
|
+
id: 'DF002',
|
|
46
|
+
title: 'Container runs as root',
|
|
47
|
+
check(ctx) {
|
|
48
|
+
const stage = ctx.stages[ctx.stages.length - 1];
|
|
49
|
+
if (!stage) return [];
|
|
50
|
+
const users = stage.instructions.filter((i) => i.instruction === 'USER');
|
|
51
|
+
if (users.length === 0) {
|
|
52
|
+
return [finding('DF002', 'high', 'No USER set in the final stage; the container runs as root.',
|
|
53
|
+
'Create and switch to a non-root user before the start command (USER app).', stage.from.line)];
|
|
54
|
+
}
|
|
55
|
+
const last = users[users.length - 1];
|
|
56
|
+
const u = (last.args.trim().split(/\s+/)[0] || '').toLowerCase();
|
|
57
|
+
if (u === 'root' || u === '0') {
|
|
58
|
+
return [finding('DF002', 'high', 'Final USER is root.',
|
|
59
|
+
'Switch to a non-root user before the start command.', last.line)];
|
|
60
|
+
}
|
|
61
|
+
return [];
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// DF003 - COPY . . (leaks the whole build context, including secrets).
|
|
66
|
+
const copyDotDot = {
|
|
67
|
+
id: 'DF003',
|
|
68
|
+
title: 'COPY . . copies the entire context',
|
|
69
|
+
check(ctx) {
|
|
70
|
+
const out = [];
|
|
71
|
+
for (const ins of ctx.instructions) {
|
|
72
|
+
if (ins.instruction !== 'COPY') continue;
|
|
73
|
+
const toks = instructionTokens(ins.args);
|
|
74
|
+
if (toks.length === 2 && toks[0] === '.' && (toks[1] === '.' || toks[1] === './')) {
|
|
75
|
+
out.push(finding('DF003', 'high', 'COPY . . copies the entire build context (risks leaking .env, secrets, .git).',
|
|
76
|
+
'Copy only what you need (manifests, lockfiles, src/) and rely on a .dockerignore.', ins.line));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// DF004 - .dockerignore missing or does not exclude .env (needs filesystem context).
|
|
84
|
+
const dockerignoreEnv = {
|
|
85
|
+
id: 'DF004',
|
|
86
|
+
title: '.dockerignore missing or does not exclude .env',
|
|
87
|
+
check(ctx) {
|
|
88
|
+
if (!ctx.hasFsContext) return []; // raw-string lint: no sibling file to check
|
|
89
|
+
if (!ctx.dockerignore || !ctx.dockerignore.exists) {
|
|
90
|
+
return [finding('DF004', 'medium', 'No .dockerignore found next to the Dockerfile.',
|
|
91
|
+
'Add a .dockerignore that excludes .env, node_modules, .git, and build output.', null)];
|
|
92
|
+
}
|
|
93
|
+
const lines = String(ctx.dockerignore.content).split(/\r?\n/).map((l) => l.trim());
|
|
94
|
+
const excludesEnv = lines.some((l) => ENV_EXCLUDE.includes(l));
|
|
95
|
+
if (!excludesEnv) {
|
|
96
|
+
return [finding('DF004', 'medium', '.dockerignore does not exclude .env.',
|
|
97
|
+
'Add a ".env" line (and ".env.*") so secrets never enter the build context.', null)];
|
|
98
|
+
}
|
|
99
|
+
return [];
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// DF005 - secret-like value hardcoded in ENV/ARG.
|
|
104
|
+
const hardcodedSecret = {
|
|
105
|
+
id: 'DF005',
|
|
106
|
+
title: 'Secret-like value hardcoded in ENV/ARG',
|
|
107
|
+
check(ctx) {
|
|
108
|
+
const out = [];
|
|
109
|
+
const isVarRef = (v) => /^\$\{?\w+\}?$/.test(v);
|
|
110
|
+
for (const ins of ctx.instructions) {
|
|
111
|
+
if (ins.instruction === 'ENV') {
|
|
112
|
+
for (const { key, value } of parseEnvPairs(ins.args)) {
|
|
113
|
+
if (SECRET_KEY.test(key) && value && value.trim() !== '' && !isVarRef(value.trim())) {
|
|
114
|
+
out.push(finding('DF005', 'critical', `ENV "${key}" hardcodes a secret-like value.`,
|
|
115
|
+
'Do not bake secrets into the image. Pass at runtime (env/secret), not in the Dockerfile.', ins.line));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
} else if (ins.instruction === 'ARG') {
|
|
119
|
+
const { key, value } = parseArg(ins.args);
|
|
120
|
+
if (SECRET_KEY.test(key) && value && value.trim() !== '' && !isVarRef(value.trim())) {
|
|
121
|
+
out.push(finding('DF005', 'critical', `ARG "${key}" has a secret-like default value baked into the image history.`,
|
|
122
|
+
'Remove the default; supply secrets at build/run time via a secret mount, not ARG.', ins.line));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// DF006 - final stage has no WORKDIR.
|
|
131
|
+
const missingWorkdir = {
|
|
132
|
+
id: 'DF006',
|
|
133
|
+
title: 'No WORKDIR set in the final stage',
|
|
134
|
+
check(ctx) {
|
|
135
|
+
const stage = ctx.stages[ctx.stages.length - 1];
|
|
136
|
+
if (!stage) return [];
|
|
137
|
+
const hasWorkdir = stage.instructions.some((i) => i.instruction === 'WORKDIR');
|
|
138
|
+
if (!hasWorkdir) {
|
|
139
|
+
return [finding('DF006', 'low', 'No WORKDIR set in the final stage; paths default to /.',
|
|
140
|
+
'Set an explicit WORKDIR (e.g. WORKDIR /app) before COPY/RUN/CMD.', stage.from.line)];
|
|
141
|
+
}
|
|
142
|
+
return [];
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const RULES = [unpinnedBase, rootUser, copyDotDot, dockerignoreEnv, hardcodedSecret, missingWorkdir];
|
|
147
|
+
|
|
148
|
+
module.exports = { RULES };
|