Security¶
Note
This page mirrors SECURITY.md at the repository root, which is the
canonical source. Links below resolve on GitHub.
Security Policy¶
Supported versions¶
stimela-ninja is early-beta software. Security fixes are applied to the latest
0.x release only; there are no long-term-support branches yet.
Version |
Supported |
|---|---|
latest |
:white_check_mark: |
older |
:x: |
Reporting a vulnerability¶
Please do not report security issues in public GitHub issues.
Report vulnerabilities privately by email to sphemakh@gmail.com (or via GitHub’s private vulnerability reporting on this repository, if enabled). Include enough detail to reproduce — affected version, backend, inputs, and the impact you observed.
We aim to acknowledge reports within a reasonable time, work with you on a fix, and credit you in the release notes if you’d like.
Security posture¶
Cab definitions — especially YAML cabs in the scabha dialect, which shinobi loads from
arbitrary files — are effectively untrusted content that can contain
executable code as data. Real cult-cargo cabs exist where command: is
inline Python/shell source (e.g. bdsf.catalog) or a dotted reference to a
function to import and call (e.g. msutils.copycol’s flavour: python);
these are non-"binary" flavours.
Recipes, unlike cab definitions, always execute¶
Everything below is about cab and config definitions — the YAML shinobi reads. It does not extend to recipes, and the distinction is easy to over-generalise, so state it plainly:
A shinobi recipe is a Python file, and ninja run file.py:target imports
and executes it. That is the project’s central design choice, not a gap:
control flow is Python, so running a recipe runs arbitrary Python with your
privileges, before any cab is dispatched or any sandbox exists.
Treat a recipe exactly as you would any script someone sent you — read it
before you run it, and don’t run one from a source you wouldn’t curl | sh
from. The guarantees in this document are about what a cab definition can
make shinobi do; none of them constrain what a recipe can do, because a
recipe is code by design.
Never eval()/exec() a cab’s command¶
shinobi never treats a non-"binary" cab’s command as code to run: every
backend shells out via subprocess.run(argv_list, ...) with a list
(never shell=True, never eval()/exec()), and
shinobi.policies.build_argv() explicitly rejects any cab whose flavour
isn’t "binary" with UnsupportedFlavourError, before argv is ever built
— so a non-executable command can never reach subprocess as argv[0] in
the first place, let alone be interpreted as code. This check runs even
during ninja run --dryrun (it’s in build_argv(), which dispatch always
calls before touching the backend), so a recipe hitting an
unsupported-flavour cab is reported clearly rather than silently mishandled.
If proper support for a code-carrying flavour is ever added: don’t
eval()/exec() the embedded string in-process. The safe shape is to write
it to a temp file and invoke a real subprocess on it (python /tmp/x.py --args, still a list argv, no shell) — same sandboxing boundary as every
other cab, no in-process code execution.
dynamic_schema and package-scoped includes are not resolved¶
dynamic_schema: dotted.path (real cult-cargo’s wsclean.yml uses this) is
a related, separate risk — resolving it means importing an arbitrary
module and calling a function it names, at cab-load time. Not implemented;
shinobi.loaders.yaml_cab warns when it sees the key rather than silently
producing a possibly-incomplete schema (a cab relying solely on
dynamic_schema with no static inputs:/outputs: loads empty).
The same boundary extends to “never import a cab package”: resolving the
package-scoped _include form would normally mean importing the named
package (importlib) to find its data directory, but that risks executing
arbitrary code from any __init__.py on the path. Instead, callers pass
an explicit package_roots={"cultcargo": Path(...)} mapping into the
loader, and a dotted name is resolved against the longest registered prefix
as a plain filesystem lookup — never through Python’s import machinery.
This applies to both loader dialects — shinobi.loaders.yaml_cab
(cab schemas) and shinobi.loaders.worker_schema (scabha-dialect config
schemas) — which share one resolve_package_root helper precisely so
neither can drift away from the rule. A package-scoped _include naming a
package with no registered root is a load error, never an import.
What a registered root does and does not promise¶
A package_roots entry is a containment boundary, not just a lookup
table: an _include chain that enters through a root may only read files
within that root. resolve_package_root constrains the dotted part of the
name, and _modelgen.contain_include constrains the file part at the join,
so neither _include: (cultcargo)../../../etc/anything.yaml nor
cult-cargo’s {"(cultcargo)": ["../../../etc/anything.yaml"]} dict form can
read outside the directory you pointed at. Paths are resolved on both sides
before comparison, so a symlink planted inside the package is caught too.
The boundary is transitive. A file legitimately included from a root
cannot re-escape with a plain relative _include: ../../../etc/anything.yaml
of its own, even though that resolves against its own directory rather than
the root. A nested package-scoped include is the one thing that changes the
boundary, replacing it with the new package’s root — that hop goes back
through the package_roots mapping, which only the caller controls.
What this does not cover: a plain relative _include below no package
root at all. ../common/base.yaml from a schema file you passed by path is
ordinary, widely-used layout, and only package_roots ever made a
containment promise — so relative includes there are unconstrained, exactly
as the file’s own directory placement already implies. Treat the schema
files you point shinobi at as you would the recipe that loads them.
Backends never shell out through a shell¶
Backends invoke commands with list-form subprocess.run — never
shell=True, never string interpolation into a shell.
Snapshots copy in-process, not through cp¶
The mutation-chain snapshots (shinobi.snapshots) copy and restore
measurement-set trees through shinobi.clonefs, which uses fcntl.ioctl,
os.copy_file_range and plain reads/writes in this process. It never
shells out at all — not to cp, not to tar.
That is the stronger version of the exec-form discipline above, and it is
deliberate: these operations take workspace paths as arguments, which are
user-supplied data, and a path is exactly the kind of string that acquires a
shell meaning it did not have when written. Copying in-process removes the
question rather than answering it, and as a bonus removes a dependency on
which GNU cp version is installed. Should a cold tier ever add tar/zstd
(Tier 2, deferred), it must use exec-form argv, as everything else here does.
Offload scripts are charset-validated before interpolation¶
Compiled offload scripts (shinobi.offload.slurm and the slurm step
backend share one script-writing module, shinobi.backends.slurm_script, so
the hardening below can’t drift between the two) embed exec-form argv
only (via shlex.join, never a shell template), and cab.name/job-name/
sbatch-option keys are charset-validated before being interpolated into a
#SBATCH line — a newline in a cab name pulled from untrusted cult-cargo
YAML would otherwise be able to smuggle in an extra #SBATCH directive. The
non-"binary" flavour guard is inherited via build_argv(), so an offloaded
recipe gets the same guarantee as a locally-run one.
If you find a way around any of these guarantees, it’s a security issue — please report it as above.