Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 36 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -418,17 +418,30 @@ jobs:
- name: Run every hook (push stage)
run: uv run pre-commit run --all-files --hook-stage pre-push --show-diff-on-failure --color always

# Short fuzzing regression over the untrusted-YAML contract. lab.yaml and
# meta.yml come from lab-provider repositories, so they are this engine's
# main untrusted input; discovery/scanner.py only rattrape (KeyError,
# ValueError, YAMLError), and anything else crashes the CLI on an unrelated
# command. These harnesses assert that contract.
# Short fuzzing regression over every input the engine does NOT produce
# itself. Each one is untrusted for a different reason, and the reason is what
# decides the contract each harness asserts:
#
# lab.yaml, meta.yml written by a lab-provider repository.
# discovery/scanner.py only catches KeyError,
# ValueError and YAMLError; anything else crashes
# the CLI on an unrelated command.
# .dsoxlab-context.json written by `use`, but living on the learner's
# disk: hand-edited, truncated by a laptop closed
# mid-write, left over by an older version.
# read_context must NEVER raise, so that harness
# has no contract exception at all.
# terraform outputs produced by an external binary whose version,
# providers and output schema all move without
# dsoxlab knowing. The harness targets what
# build_inventory does with the decoded document,
# not the json.loads that precedes it.
#
# This is a regression gate, not a fuzzing campaign: a short seeded run keeps
# the harnesses alive and catches the obvious breakages. Deep runs are done
# locally with a persistent corpus.
fuzz:
name: Fuzz the untrusted-YAML contract (short run)
name: Fuzz every untrusted input (short run)
needs: [zizmor, actionlint, poutine]
runs-on: ubuntu-24.04
timeout-minutes: 15
Expand Down Expand Up @@ -472,6 +485,23 @@ jobs:
-dict=fuzz/dict/yaml_contract.dict \
-atheris_runs=20000 -max_len=4096

# Pas de -dict ici : yaml_contract.dict porte des mots-clés du contrat
# YAML, sans rapport avec un document JSON. Un dictionnaire hors sujet
# coûte du budget sans guider la mutation.
- name: Fuzz the local session context
run: |
mkdir -p "${RUNNER_TEMP}/fuzz-context"
uv run --group fuzz python fuzz/fuzz_context_json.py \
"${RUNNER_TEMP}/fuzz-context" fuzz/corpus/context_json/ \
-atheris_runs=20000 -max_len=4096

- name: Fuzz the Terraform outputs the inventory is built from
run: |
mkdir -p "${RUNNER_TEMP}/fuzz-tfout"
uv run --group fuzz python fuzz/fuzz_terraform_outputs.py \
"${RUNNER_TEMP}/fuzz-tfout" fuzz/corpus/terraform_outputs/ \
-atheris_runs=20000 -max_len=4096

# A crash writes a `crash-*` reproducer next to the harness. Keep it: it
# is the exact input needed to replay the failure locally.
- name: Upload crash reproducers
Expand Down
10 changes: 9 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,18 @@ repos:
hooks:
- id: trailing-whitespace
exclude: '(^LICENSE$|\.md$)'
# `fuzz/corpus/` est hors du périmètre de l'hygiène, et pas par
# commodité : ce sont des graines DÉLIBÉRÉMENT malformées, qui existent
# pour être données à un fuzzer. Un JSON tronqué au milieu d'une chaîne
# reproduit un portable refermé pendant l'écriture ; le « réparer » ou le
# refuser retire au corpus le cas qu'il porte. Le contrat des documents,
# lui, est tenu par les harnais eux-mêmes, pas par ces hooks.
- id: end-of-file-fixer
exclude: '^LICENSE$'
exclude: '(^LICENSE$|^fuzz/corpus/)'
- id: check-yaml
exclude: '^fuzz/corpus/'
- id: check-json
exclude: '^fuzz/corpus/'
- id: check-toml
- id: check-merge-conflict
- id: check-case-conflict
Expand Down
34 changes: 34 additions & 0 deletions CHANGELOG.fr.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,40 @@ et le projet suit le [versionnage sémantique](https://semver.org/lang/fr/).

## [Non publié]

## [0.1.64] - 2026-08-24

### Corrigé

- **Un output Terraform mal formé faisait planter la construction de
l'inventaire.** `{"hosts": {"value": "10.99.0.11"}}` suffisait : le code
prenait la valeur pour un objet et appelait `.items()` dessus, ce qui rendait
un `AttributeError` au moment de jouer un lab, sans jamais dire que la cause
était un state Terraform périmé. Ce document fait trente-quatre octets et
personne ne l'avait écrit à la main : c'est le nouveau harnais de fuzzing qui
l'a trouvé, en moins de trente mille exécutions.

### Ajouté

- **Le fuzzing couvre désormais toutes les entrées que le moteur ne produit pas
lui-même.** Deux harnais s'ajoutent aux deux existants, et chacun assère un
contrat différent, parce que chaque entrée est non fiable pour une raison
différente :

- **`.dsoxlab-context.json`** vit sur le disque de l'apprenant : édité à la
main par curiosité, tronqué par un portable refermé au mauvais moment,
laissé par une version ancienne. Son harnais n'a **aucune exception de
contrat**, et c'est tout son propos : `read_context` promet de rendre un
contexte vide plutôt que de lever, parce que perdre le contexte coûte un
`dsoxlab use` alors qu'une exception coûte la CLI entière.
- **Les outputs Terraform** viennent d'un binaire externe dont la version,
les providers et le schéma de sortie bougent sans que dsoxlab le sache. Le
harnais vise ce que `build_inventory` **fait** du document, et non le
`json.loads` qui le précède : celui-là est déjà protégé, et le fuzzer n'y
mesurerait que la bibliothèque standard.

Le commentaire du job de CI énumère les entrées couvertes et dit, pour
chacune, pourquoi elle n'est pas fiable.

## [0.1.63] - 2026-08-24

- **La documentation décrivait un produit qui n'existe pas.** Trois
Expand Down
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.1.64] - 2026-08-24

### Fixed

- **A malformed Terraform output crashed the inventory build.**
`{"hosts": {"value": "10.99.0.11"}}` was enough: the code took the value for a
mapping and called `.items()` on it, producing an `AttributeError` at the very
moment someone runs a lab, and never saying that the cause was a stale
Terraform state. That document is thirty-four bytes and nobody had written it
by hand: the new fuzz harness found it in under thirty thousand runs.

### Added

- **Fuzzing now covers every input the engine does not produce itself.** Two
harnesses join the two existing ones, and each asserts a different contract,
because each input is untrusted for a different reason:

- **`.dsoxlab-context.json`** lives on the learner's disk: hand-edited out of
curiosity, truncated by a laptop closed mid-write, left behind by an older
version. Its harness has **no contract exception at all**, and that is the
whole point: `read_context` promises to return an empty context rather than
raise, because losing the context costs one `dsoxlab use` where an exception
costs the entire CLI.
- **Terraform outputs** come from an external binary whose version, providers
and output schema all move without dsoxlab knowing. The harness targets what
`build_inventory` **does** with the document, not the `json.loads` before it:
that one is already guarded, and fuzzing it would only measure the standard
library.

The CI job's header comment now lists the covered inputs and says, for each,
why it is considered untrusted.

## [0.1.63] - 2026-08-24

- **The documentation described a product that does not exist.** Three claims
Expand Down
1 change: 1 addition & 0 deletions fuzz/corpus/context_json/champ-inconnu.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"section": "linux", "futur": {"a": 1}}
1 change: 1 addition & 0 deletions fuzz/corpus/context_json/complet.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"section": "linux", "level": "l1", "lang": "fr", "active_lab": "l1-permissions", "active_target": "rhel", "active_provider": "kvm", "course_pos": 3}
1 change: 1 addition & 0 deletions fuzz/corpus/context_json/position-negative.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"course_pos": -5}
1 change: 1 addition & 0 deletions fuzz/corpus/context_json/position-non-numerique.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"course_pos": "trois"}
1 change: 1 addition & 0 deletions fuzz/corpus/context_json/racine-chaine.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"texte"
1 change: 1 addition & 0 deletions fuzz/corpus/context_json/racine-liste.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[1, 2, 3]
1 change: 1 addition & 0 deletions fuzz/corpus/context_json/racine-nulle.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
null
1 change: 1 addition & 0 deletions fuzz/corpus/context_json/section-liste.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"section": ["linux"]}
1 change: 1 addition & 0 deletions fuzz/corpus/context_json/tronque.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"section": "lin
1 change: 1 addition & 0 deletions fuzz/corpus/context_json/vide.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
1 change: 1 addition & 0 deletions fuzz/corpus/terraform_outputs/avec-bastion.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"hosts": {"value": {"un.lab": "10.99.0.11"}}, "bastion": {"value": {"public_ip": "203.0.113.7", "fqdn": "bastion.exemple", "user": "outscale"}}}
1 change: 1 addition & 0 deletions fuzz/corpus/terraform_outputs/bastion-sans-ip.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"bastion": {"value": {"fqdn": "b.exemple"}}}
1 change: 1 addition & 0 deletions fuzz/corpus/terraform_outputs/hosts-liste.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"hosts": {"value": ["10.99.0.11"]}}
1 change: 1 addition & 0 deletions fuzz/corpus/terraform_outputs/ip-numerique.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"hosts": {"value": {"un.lab": 167837707}}}
1 change: 1 addition & 0 deletions fuzz/corpus/terraform_outputs/outputs-aplatis.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"hosts": {"un.lab": "10.99.0.11"}}
1 change: 1 addition & 0 deletions fuzz/corpus/terraform_outputs/outputs-encapsules.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"hosts": {"value": {"un.lab": "10.99.0.11", "deux.lab": "10.99.0.12"}, "type": ["object", {"un.lab": "string", "deux.lab": "string"}]}}
1 change: 1 addition & 0 deletions fuzz/corpus/terraform_outputs/sans-hosts.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"autre_chose": {"value": 42}}
1 change: 1 addition & 0 deletions fuzz/corpus/terraform_outputs/value-non-objet.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"hosts": {"value": "10.99.0.11"}}
1 change: 1 addition & 0 deletions fuzz/corpus/terraform_outputs/vide.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
60 changes: 60 additions & 0 deletions fuzz/fuzz_context_json.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Fuzz harness for the local session context.

`.dsoxlab-context.json` is untrusted for a different reason than `lab.yaml` and
`meta.yml`: it does not come from a lab-provider repository, it comes from the
learner's own disk. It is written by `dsoxlab use`, edited by hand when someone
is curious, truncated when a laptop is closed mid-write, and left behind by an
older version of the tool.

**This harness has no contract exception, and that is the point.**
`read_context` promises to return an empty context whenever the file is absent,
unreadable or malformed: losing the context costs the learner one `dsoxlab use`,
where an exception costs the whole CLI, without even naming the file to delete.
So any exception at all is a failure here, unlike the YAML harnesses where
`ValueError` is the documented rejection signal.

Run it — scratch dir FIRST, seed corpus second (see fuzz_lab_yaml.py):
mkdir -p /tmp/fuzz-context
uv run --group fuzz python fuzz/fuzz_context_json.py \
/tmp/fuzz-context fuzz/corpus/context_json/ -atheris_runs=50000

See fuzz/fuzz_lab_yaml.py for why instrumentation is scoped to dsoxlab only and
why the seed corpus is what makes this harness effective.
"""

from __future__ import annotations

import sys
import tempfile
from pathlib import Path

import atheris

with atheris.instrument_imports(include=["dsoxlab"]):
from dsoxlab.config import read_context

_TMPDIR = Path(tempfile.mkdtemp(prefix="dsoxlab-fuzz-context-"))


def test_one_input(data: bytes) -> None:
"""Feed one fuzzer-generated document to read_context."""
# Bytes are written raw, without decoding first: a context file of arbitrary
# bytes is exactly the case that used to slip through, because
# UnicodeDecodeError descends from ValueError and not from OSError.
try:
(_TMPDIR / ".dsoxlab-context.json").write_bytes(data)
except OSError:
return

# No `except` for a contract: read_context must not raise, ever.
read_context(_TMPDIR)


def main() -> None:
atheris.Setup(sys.argv, test_one_input)
atheris.Fuzz()


if __name__ == "__main__":
main()
115 changes: 115 additions & 0 deletions fuzz/fuzz_terraform_outputs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Fuzz harness for the Terraform outputs the inventory is built from.

Terraform outputs are untrusted for a third reason again: they are produced by
an **external binary** whose version, providers and output schema all move
without dsoxlab knowing. `terraform output -json` wraps every value in
`{"name": {"value": …, "type": …}}`, but a state written by another version, a
provider that renamed an output, or a hand-edited state all reach the same
reader.

**The target is the consumption, not the parsing.** `read_terraform_outputs`
does I/O and one `json.loads` whose failure is already caught; fuzzing it would
mostly measure `json.loads`. What reaches the user as a traceback is what
`build_inventory` does with the decoded document, so that is what is fuzzed
here, with a `meta.yml` describing two hosts and a target, i.e. the shape a
`runtime: vm` lab actually goes through.

Contract: `build_inventory` may reject a document it cannot honour, but only by
the exceptions listed below. Anything else is a crash for the learner, at the
moment they run a lab, with no clue that the cause is a stale Terraform state.

Run it — scratch dir FIRST, seed corpus second (see fuzz_lab_yaml.py):
mkdir -p /tmp/fuzz-tfout
uv run --group fuzz python fuzz/fuzz_terraform_outputs.py \
/tmp/fuzz-tfout fuzz/corpus/terraform_outputs/ -atheris_runs=50000

See fuzz/fuzz_lab_yaml.py for why instrumentation is scoped to dsoxlab only and
why the seed corpus is what makes this harness effective.
"""

from __future__ import annotations

import json
import sys
import tempfile
from pathlib import Path

import atheris

with atheris.instrument_imports(include=["dsoxlab"]):
from dsoxlab.infra.inventory import InfraNotProvisioned, build_inventory
from dsoxlab.models.repo import RepoMetadata

#: Rejeter un document qu'on ne peut pas honorer est légitime ; le faire par une
#: exception que personne n'attend ne l'est pas.
#:
#: `InfraNotProvisioned` en fait partie, et le harnais l'a appris à sa première
#: exécution, sur la graine `{}` : un document sans adresse est le cas NORMAL du
#: premier lancement ou de l'après-`destroy`, et la CLI le rend en une phrase.
#: Une exception dédiée pour un état attendu, c'est exactement le patron que ce
#: dépôt applique, et un harnais qui l'aurait comptée comme un crash aurait
#: réclamé de le défaire.
CONTRACT_EXCEPTIONS = (KeyError, ValueError, InfraNotProvisioned)

_TMPDIR = Path(tempfile.mkdtemp(prefix="dsoxlab-fuzz-tfout-"))
_META_YML = _TMPDIR / "meta.yml"
_META_YML.write_text(
"""\
repo:
id: fuzz
category: demo
infra:
provider: kvm
network: fuzz-net
cidr: 10.99.0.0/24
hosts:
- name: un.lab
distro: alma10
- name: deux.lab
distro: debian13
""",
encoding="utf-8",
)
_REPO_META = RepoMetadata.from_yaml(_META_YML)


def test_one_input(data: bytes) -> None:
"""Feed one fuzzer-generated outputs document to build_inventory."""
# Raw decode, not FuzzedDataProvider — see fuzz_lab_yaml.py: the provider
# would reinterpret the seed files and make the corpus inert.
try:
document = data.decode("utf-8")
except UnicodeDecodeError:
return

try:
outputs = json.loads(document)
except (json.JSONDecodeError, RecursionError):
return
if not isinstance(outputs, dict):
# `read_terraform_outputs` annotates its result as a mapping, and every
# caller obtains it from there. A non-mapping is out of scope for this
# harness, not a defect it should report.
return

try:
build_inventory(
_REPO_META,
terraform_outputs=outputs,
target_fqdn="un.lab",
roles={"server": "deux.lab"},
)
except CONTRACT_EXCEPTIONS:
return
except RecursionError:
raise


def main() -> None:
atheris.Setup(sys.argv, test_one_input)
atheris.Fuzz()


if __name__ == "__main__":
main()
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "dsoxlab"
version = "0.1.63"
version = "0.1.64"
description = "Turn declarative exercises into reproducible, runnable and verifiable lab environments"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
Loading
Loading