Skip to content

Run megalinter as non-root in container#8120

Merged
nvuillam merged 20 commits into
oxsecurity:mainfrom
Wuodan:upstream-PR/06-Run-container-as-non-root
Jun 20, 2026
Merged

Run megalinter as non-root in container#8120
nvuillam merged 20 commits into
oxsecurity:mainfrom
Wuodan:upstream-PR/06-Run-container-as-non-root

Conversation

@Wuodan

@Wuodan Wuodan commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Run megalinter as non-root in container

This PR adds an opt-in mode to run megalinter as a non-root user inside the container.

Problem

Currently megalinter always runs as root in containers.

For users on POSIX systems (Linux and macOS) this causes problems as files on host created by megalinter will be owned by root and thus not writable for normal users.

Especially in automated environments this can be a real problem as the host user might not have sudo permissions.

I tested this and files on host changed by megalinter as root keep their owner. Currently this affects generated files.

Also running processes as root in containers is considered bad practice.

Solution

For a software like megalinter to properly share files with the host, this is needed:

  1. Software and processes in the containers image must not rely on /root
  2. For POSIX hosts the user's UID and GID from the host must be used dynamically.
    Hardcoding 1000:1000 does not work. It is the default only for the very first user on Linux.
  3. To run processes as non-root in containers there are 2 options:
    1. Directly start the container as the non-root user with docker run --user $UID:$GID
    2. Start the container as root and switch to the target runtime user in the entrypoint

Software and processes

I identified the installations of PHP, .NET, Rust, npm and Salesforce to use /root and thus cause problems. And additionally the SSH server feature in the container needed root.

For each of these there are 2 commits:

  • definition: the base change
  • generated: the generated change from the definition

Run processes as non-root in containers

This PR follows the 3.2 pattern: start the container as root and switch to the target runtime user in the entrypoint.

The logic was carried over from the
aicage entrypoint.sh:244.

Note:
3.1 is to be preferred when possible. But some tools require a real user account with a valid HOME directory set up by the OS.
During development I stayed on this path almost to the end, but Salesforce needs a real user.
Without Salesforce we could use the 3.1 approach.

Commit overview

The commits follow a TDD order where tests are added early and fail first. Then they gradually start to pass. This may help retrace during review why parts of the change are necessary.

If review would be easier in smaller steps, the runtime-specific fixes for PHP, .NET, Rust, npm, Salesforce, and SSH can also be split into separate precursor PRs which would need to be merged first.

I kept them together here because the non-root mode and the runtime fixes are closely connected and the test progression is easier to follow in one series even though intermediate commits intentionally contain failing tests.

Commit overview: 0. Baseline (no commits)

Before any other changes I ran:

  • hatch run build:build-py -- --delete-dockerfiles
  • 'hatch run build:docs'

which both updated some files and gave me a stable baseline.

Those changes/commits are not part of this PR and now removed form this PR.

Especially the removal of stale Dockerfiles causes merge conflicts as dependabot still updates them.

Commit overview: 1. Runtime image tests for root user (1st commit)

Commit:

  • feat: add root runtime smoke tests for PHP, .NET, Rust, npm, Salesforce and SSH

For each of PHP, .NET, Rust, npm and Salesforce add a test which runs the tool. Each tool is tested twice:

  • direct 'docker run'
  • through 'megalinter'

For SSH server add only a direct 'docker run' test as 'megalinter' does not provide a surface to test.

These tests all pass here.

Commit overview: 2. Run container processes as non-root (2nd and 3rd commit)

Commits:

  • feat: add opt-in mega-linter-runner user-map mode (definition)
  • feat: add opt-in mega-linter-runner user-map mode (generated)

The definition commit changes:

  1. entrypoint.sh: sets up the user by a separate script sh/setup-runtime-user.
    1. For user root the entrypoint runs through directly
    2. For non-root users:
      1. The entrypoint process replaces itself early (with exec) with a process running the setup-runtime-user script
      2. setup-runtime-user sets up the user and replaces its process with a fresh call to entrypoint.sh as the non-root user
      3. This non-root process of entrypoint.sh runs through the end of the entrypoint script
      4. This way no process with root user remains in the container.
  2. megalinter on host: passes the correct user UID:GID as env vars when running the container. On non-POSIX (Windows) it uses 1000:1000.
  3. build.py: Add new sh/setup-runtime-user when building images

The follow-up generated commit applies the definition changes throughout the project.

Commit overview: 3. Runtime image tests for non-root user (4th commit)

Commits:

  • feat: add non-root runtime smoke tests for PHP, .NET, Rust, npm, Salesforce and SSH

This expands those tests so each scenario is also executed for non-root user in container.

At this point all those non-root runtime tests fail except for npm.

Commit overview: 4. Fix PHP, .NET, Rust, npm, Salesforce and SSH (6x2 = 12 commits)

The fix for each of PHP, .NET, Rust, npm, Salesforce and SSH is again split into 2 commits:

  • definition: the actual change
  • generated: the definition changes applied

For each fix (2 commits) the respective runtime tests with non-root user then pass. At the end all those tests pass.

Risks and Arguments

  • medium
    • PHP, .NET, Rust, npm or Salesforce might not behave well with new installation methods
      • Each is covered in a new test
      • I have another project where all except Salesforce have the same setup and test each there too
      • Salesforce is a standard XDG installation
  • low
    • entrypoint.sh might cause problems with the change
      • the change to entrypoint.sh itself is small
    • switching from root to non-root fails during container start
      • it is disabled by default
  • very low
    • other software not working with non-root user in container
      • no other software depending on /root found in the code
      • the mode is disabled by default
    • SSH server fails to start as non-root
      • SSH server and the non-root mode are both optional

Fixes

Proposed Changes

  1. Install all software independent of /root
  2. Enable SSH server as non-root
  3. Run container processes as non-root (matching host user)

Readiness Checklist

Author/Contributor

  • Add entry to the CHANGELOG listing the change and linking to the corresponding issue (if appropriate)
  • If documentation is needed for this change, has that been included in this pull request

Reviewing Maintainer

  • Label as breaking if this is a large fundamental change
  • Label as either automation, bug, documentation, enhancement, infrastructure, or performance

@nvuillam

Copy link
Copy Markdown
Member

It seems very interesting !
Please can you merge conflicts

It seems to have many impacts... @echoix @bdovaz I'd love your opinion here :)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Wuodan added 16 commits June 17, 2026 02:35
…ce and SSH (oxsecurity#1975)

For each of PHP, .NET, Rust, npm and Salesforce add a test which runs the tool. Each tool is tested twice:
 - direct 'docker run'
 - through 'megalinter'

 For SSH server add only a direct 'docker run' test as 'megalinter' does not provide a surface to test.
…urity#1975)

Add --user-map and --no-user-map to control whether the container runs as root or as a non-root user.

The default is root.

On POSIX systems, --user-map uses the current user. On other hosts, it falls back to uid/gid 1000:1000.
@Wuodan Wuodan force-pushed the upstream-PR/06-Run-container-as-non-root branch from 8a97189 to 9d914e6 Compare June 17, 2026 00:35
@Wuodan

Wuodan commented Jun 17, 2026

Copy link
Copy Markdown
Contributor Author

Please can you merge conflicts

Done. I had 2 "baseline" commits which are now removed from this PR.

Wuodan added 3 commits June 17, 2026 04:42
…urity#1975)

Follow up triggered by linter errors:

- Remove accidentally added LOGNAME env var
- Whitelist adduser, deluser, and delgroup in cspell for 'sh/setup-runtime-user'
@Wuodan

Wuodan commented Jun 17, 2026

Copy link
Copy Markdown
Contributor Author

I added 3 commits to address linter errors:

  • fix line to long in build.py
  • add shell commands to cspell config
  • one accidentally added env var removed

@nvuillam nvuillam left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it was one of the most requested update for years, thanks a lot for the PR :)

@nvuillam nvuillam merged commit 00b11d9 into oxsecurity:main Jun 20, 2026
10 checks passed
@Wuodan Wuodan deleted the upstream-PR/06-Run-container-as-non-root branch June 25, 2026 11:35
@Wuodan Wuodan restored the upstream-PR/06-Run-container-as-non-root branch June 25, 2026 16:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MegaLinter applying fixes sets root:root as the file owner

3 participants