# sec-sbom — an SBOM parser and a dependency-confusion checker

Parse a real CycloneDX SBOM shape, walk its dependency graph, and flag the components that are
a **dependency-confusion** risk: an internal-looking name that is *already claimable* on the
public package index. The brief is the same text as the page
(`illustrated/10-security/exercise-sbom-parser.html`); the concept is walked first on
`illustrated/10-security/supply-chain-sbom.html`.

This one runs on your Mac, not in the browser — it's plain-stdlib Python, but the local track
certifies it for real rather than skipping it.

## What to implement

- **`src/sec_sbom/sbom.py` — `load_components(sbom) -> dict[str, Component]`.** Every component
  in `sbom["components"]`, keyed by its `bom-ref`, **plus** the root application at
  `sbom["metadata"]["component"]` — `graph.py` needs the root's ref to start walking from. A
  component missing `"version"` becomes `version=""`, not a `KeyError`.
- **`src/sec_sbom/graph.py` — `build_dependency_graph(sbom) -> dict[str, set[str]]`.** Mirror
  `sbom["dependencies"]` exactly: `{ref: set(dependsOn)}`, and a ref with `dependsOn: []` is
  still a key mapped to `set()`, not dropped.
- **`src/sec_sbom/graph.py` — `reachable_from(graph, root) -> set[str]`.** Every ref reachable
  from `root` by following edges any number of hops, **excluding** `root` itself — this is what
  makes a two-hop dependency (`requests -> mycorp-billing`) count.
- **`src/sec_sbom/confusion.py` — `normalize_name(name) -> str`.** Fold case and treat `-`, `_`,
  `.` as the same separator — `MyCorp_Telemetry`, `mycorp-telemetry` and `mycorp.telemetry` are
  one published project on PyPI (PEP 503) and on npm.
- **`src/sec_sbom/confusion.py` — `find_confusion(sbom, internal_prefixes, public_index) ->
  list[ConfusionFinding]`.** Every component reachable from the root whose *normalized* name
  starts with a *normalized* `internal_prefixes` entry **and** is a key of `public_index` (also
  compared normalized). Sort the result by `name`.

## Run it

```sh
cd exercises/sec-sbom && uv sync && uv run pytest -q
```

`uv sync` installs `pytest` into `.venv/`. The untouched starter fails all 6 checks —
`load_components`, `build_dependency_graph`, `reachable_from` and `find_confusion` are all `...`.

## The checks (`tests/`)

- `test_load_components_reads_root_and_every_declared_component` — 7 components (root + 6),
  the root's own name/version, one child's name/version.
- `test_build_dependency_graph_matches_the_dependencies_array` — the root's `dependsOn` set,
  `requests`'s single transitive edge to `mycorp-billing`, and a leaf's empty set is still a key.
- `test_find_confusion_flags_direct_public_match_and_spares_private_only_name` — `mycorp-auth`
  (public, prefix match) is flagged with its published versions; `mycorp-internal-tool` (prefix
  match, **not** public) is not — nobody can be confused with a name nobody has published.
- `test_find_confusion_ignores_name_that_merely_contains_the_prefix` — `django-mycorp-plugin` is
  public but does not **start with** `mycorp`; a substring search over-flags it, a true prefix
  match does not.
- `test_find_confusion_normalizes_case_and_separators_before_matching` — `MyCorp_Telemetry` only
  matches `mycorp-telemetry` in `public_index` once case and separators are folded.
- `test_find_confusion_walks_the_full_dependency_graph_not_just_direct_children` —
  `mycorp-billing` is two hops from the root (via `requests`); a checker that only inspects the
  root's direct dependencies never reaches it.

Done when `uv run pytest -q` prints **6 passed**, then press "mark done" on the page.

## If you get stuck

- **The graph, not the flat list** — `sbom["components"]` is flat regardless of depth; it is
  `sbom["dependencies"]` that says who depends on whom. `find_confusion` must call
  `reachable_from(build_dependency_graph(sbom), root_ref)`, not just iterate every component.
- **Normalize both sides** — `find_confusion` compares normalized names against **both**
  `internal_prefixes` and `public_index`'s keys. Normalizing only one side is how
  `MyCorp_Telemetry` silently stops matching `mycorp-telemetry`.
- **Prefix, not substring** — `normalized_name.startswith(prefix)`, never `prefix in
  normalized_name`. The second over-flags any public package whose name happens to contain the
  word anywhere.
- **Reading a red row** — `uv run pytest -x --tb=short` stops at the first failure and prints
  the assert message, which names the component and the value it computed.
