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
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,42 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
drift apart on what a safe URI looks like, and `check.RepoRelativePath`
for the threat model's repository-relative references.

- **Threat-model review guidance.** Three collections that help a reader
navigate a model rather than describe the system
([#9](https://github.com/sofired/tracedoc/issues/9)):

- `criticality[]` — what each `priority` level means for this project,
with worked examples. `priority` is schema-owned, but where its
boundaries fall is a project's judgement; recording it is what makes a
ranking reviewable rather than merely asserted.
- `top_abuse_path_links` — the abuse paths to read first, as declared
threat IDs. Deliberately not derived from `priority`: editorial order
is a judgement, and repeating every `critical` threat makes none.
- `focus_paths[]` — where in the repository a threat actually lives,
with the threats that make each location worth reading. No other
collection carries the link from a threat to an artifact.

- Configuration: `threat_model.limits` (`min_criticality_examples`,
`min_top_abuse_paths`, `max_top_abuse_paths`), each defaulting to `0`
meaning unbounded, and an eighth coverage switch,
`require_criticality_for_every_priority`. Quantitative policy is
configuration rather than schema rule, because the right numbers differ
between a small service and a platform. A negative bound, or a maximum
below its own minimum, is rejected when the configuration loads.

- **No `quality_checks` collection**, deliberately. A model's own
completeness self-assessment duplicates what this tool already proves —
entry-point, boundary, asset, flow, control, risk and evidence coverage,
requirement resolution, and required assumptions and open questions are
each enforced — and a hand-maintained claim can go stale and contradict
the validator beside it. `docs/schema-threat-model.md` carries the
mapping. The claims that remain are process notes about how a review was
run, which belong in the pull request that changed the model.

This is schema-or-nothing rather than schema-versus-template: unknown
members are rejected, so a collection absent from the schema cannot be in
a document for a consumer template to render.

### Fixed

- **Control characters are now rejected in every validated string, as
Expand Down
21 changes: 21 additions & 0 deletions cmd/tracedoc/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,27 @@ func TestValidateThreatModel(t *testing.T) {
doc.TrustBoundaries[0].Source = "COMP-404"
},
},
{
name: "unsupported criticality level",
want: `criticality[0].level: unsupported value "urgent"`,
mutate: func(doc *threats.Document) {
doc.Criticality[0].Level = "urgent"
},
},
{
name: "absolute focus path",
want: "focus_paths[0].path: expected a relative path",
mutate: func(doc *threats.Document) {
doc.FocusPaths[0].Path = "/etc/passwd"
},
},
{
name: "unknown threat in the headline list",
want: `top_abuse_path_links[0]: unknown threat "THRT-404"`,
mutate: func(doc *threats.Document) {
doc.TopAbusePathLinks = []string{"THRT-404"}
},
},
{
name: "declared asset that no threat analyses",
want: `asset "AST-002" is declared but never analysed`,
Expand Down
28 changes: 26 additions & 2 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,13 @@ Example:
"require_entry_point_coverage": true,
"require_control_coverage": true,
"require_risk_coverage": true,
"require_evidence_per_threat": true
"require_evidence_per_threat": true,
"require_criticality_for_every_priority": true
},
"limits": {
"min_criticality_examples": 2,
"min_top_abuse_paths": 3,
"max_top_abuse_paths": 10
},
"render": {
"source_name": "threats.json",
Expand Down Expand Up @@ -116,6 +122,7 @@ no section is exit `2`.
| `evidence_statuses` | non-empty list of allowed `planned_evidence[].status` values |
| `reference_hosts` | optional list of lowercase multi-label DNS names; hosts an external reference may use |
| `coverage` | boolean switches for the declared-entity coverage rules; see below |
| `limits` | quantitative bounds on the review-guidance collections; see below |
| `render` | presentation strings; see below |

These four vocabularies are a project's own workflow labels. The ones the
Expand All @@ -139,9 +146,26 @@ the reproducible default.
rejects, and what "analysed" means, is specified once in
[schema-threat-model.md](schema-threat-model.md#coverage).

`require_criticality_for_every_priority` is the eighth switch: it demands a
`criticality` entry for each of the four `priority` levels.

Each switch defaults to `false` when omitted, so a project can adopt the
document type first and tighten coverage as the model fills in.

`limits`:

| Member | Bounds |
| -------------------------- | --------------------------------------------- |
| `min_criticality_examples` | worked examples required per `criticality` entry |
| `min_top_abuse_paths` | fewest entries in `top_abuse_path_links` |
| `max_top_abuse_paths` | most entries in `top_abuse_path_links` |

Each defaults to `0`, which disables that bound. Negative values are
rejected, as is a maximum below its own minimum — a bound no document can
satisfy is a configuration error worth catching at load time rather than as
a puzzling rejection later. The schema says what these collections are; how
much of them a project expects is its own call.

### `render` (per section)

| Member | Used for |
Expand Down Expand Up @@ -177,7 +201,7 @@ receives:
`.Ownership`, `.Standards`; for threat models: `.PriorityCounts`,
`.TreatmentCounts`, `.Diagrams`, `.Assets`, `.Boundaries`, `.Flows`,
`.EntryPoints`, `.Decisions`, `.Risks`, `.Controls`, `.Evidence`,
`.Sections`; and
`.Sections`, `.TopAbusePathLinks`, `.FocusPaths`; and
- the template functions `anchor`, `anchorHref`, `htmlText`, `inlineCode`,
`inlineValues`, `issueURL`, `join`, `linkDestination`, `linkLabel`,
`lower`, `owner`, `prose`, and `table`, plus `add1` for threat models
Expand Down
83 changes: 83 additions & 0 deletions docs/schema-threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ will demonstrate it is handled and who carries whatever risk remains.
| `planned_evidence` | array | non-empty; see below |
| `observability` | array | required, may be empty; see below |
| `threats` | array | non-empty; see below |
| `criticality` | array | required, may be empty; see below |
| `top_abuse_path_links` | array | required, may be empty; declared threat IDs |
| `focus_paths` | array | required, may be empty; see below |
| `supersessions` | array | required, may be empty; shared supersession rules with threat IDs |

Every array member is **required to be present**, even when empty. `compare`
Expand Down Expand Up @@ -350,6 +353,55 @@ present:
- `accept`, `avoid`, and `transfer` each require `treatment_rationale`:
all three record a decision not to build a control.

## `criticality[]`

| Member | Type | Rules |
| ------------ | ------ | -------------------------------------------------- |
| `level` | string | a `priority` value (schema-owned); unique |
| `definition` | string | non-empty; what this level means for this project |
| `examples` | array | non-empty, unique; worked examples at this level |

`priority` is a schema-owned vocabulary, but what separates one level from
the next is a project's own judgement about blast radius. Without that
judgement written down, a reader cannot tell a miscalibrated ranking from an
honest disagreement about the scale — which makes the model's most
consequential field the one nobody can check.

The level is the record's key, so there is no separate identifier — nothing
links to a calibration entry. Duplicated levels are rejected.

Two bounded configuration switches govern how much calibration a project
expects: `require_criticality_for_every_priority` demands an entry for each
of the four levels, and `min_criticality_examples` sets a floor on
`examples`. Both are [configuration](config.md#threat_model), because the
right answer differs between a small service and a platform.

## `top_abuse_path_links`

A required array, possibly empty, of unique declared threat IDs: the abuse
paths a reviewer should follow first. Bounded by the configured
`min_top_abuse_paths` and `max_top_abuse_paths`.

This is deliberately **not** derived from `priority`. "The paths to read
first" is an editorial judgement about narrative order, and a list that
merely repeats every `critical` threat has made no such judgement. A model
with fifteen threats and three genuinely instructive attack narratives
should say so.

## `focus_paths[]`

| Member | Type | Rules |
| -------------- | ------ | -------------------------------------------- |
| `path` | string | repository-relative, unique; same rules as a [reference path](#references) |
| `why` | string | non-empty; why this location deserves scrutiny |
| `threat_links` | array | non-empty, unique; declared threat IDs |

The model's link from a threat to where that threat actually lives. No
other collection carries it: `components` describe what the system is made
of and `planned_evidence` describes what will test it, but neither tells a
reviewer what to read. The path is the record's key, and as with every path
this schema carries, the tool never opens it.

## Ownership

`threats[]`, `controls[]`, and `planned_evidence[]` each carry an `owner`
Expand Down Expand Up @@ -392,6 +444,7 @@ incrementally can turn one off rather than deleting the entity:
| `require_control_coverage` | a control no threat links to |
| `require_risk_coverage` | a risk no threat links to |
| `require_evidence_per_threat` | a threat no planned evidence names |
| `require_criticality_for_every_priority` | a `priority` level with no [calibration entry](#criticality) |

Coverage is credited **only** from `threats[]` and, for the last rule, from
`planned_evidence[].threat_links`. A boundary named by a data flow, or a
Expand All @@ -406,6 +459,36 @@ false positive worth rejecting: a threat that crosses the same boundary by a
different route has not examined this surface, and accepting it would
certify an entry point nobody reviewed.

## Why there is no `quality_checks`

A threat model may be tempted to carry its own completeness self-assessment
— a list of claims like "all entry points are covered", each marked
complete. This schema deliberately has no such collection, because for the
claims worth making the tool already proves them, and a hand-maintained
claim can go stale and contradict the validator that ran beside it:

| A claim of this kind | What already enforces it |
| -------------------- | ------------------------ |
| All entry points are covered | `require_entry_point_coverage` |
| Every trust boundary appears in a threat | `require_boundary_coverage` |
| Every asset, flow, control, and risk is analysed | the remaining [coverage switches](#coverage) |
| Every threat has planned evidence | `require_evidence_per_threat` |
| Requirement traceability resolves | `validate -requirements` |
| Assumptions and open questions are explicit | `assumptions` and `open_questions` are required members |

The claims left over — "the review reflected a conversation with the
architect", "both deployment shapes were considered" — are process notes
about how a review was conducted rather than statements about the system.
They belong in the pull request that changed the model, where they can be
read against the diff.

Note that this is a schema-or-nothing choice, not a schema-versus-template
one. Unknown members are rejected
([shared lexical contract](schema.md#shared-lexical-contract)), so a
collection absent from the schema cannot appear in a document at all, and a
consumer template has nothing to render. Anything a project needs to record
has to be a schema member.

## Which vocabularies are schema-owned

`likelihood`, `severity`, `priority`, `treatment`, and `decisions[].status`
Expand Down
3 changes: 3 additions & 0 deletions docs/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ validation:
- exactly one top-level JSON value; and
- a top-level `document_type` member naming the schema family.

Every declared array of strings rejects duplicate entries, so a list can
never assert the same thing twice.

Every validated string field is non-blank (a value of only whitespace is
rejected), limited to 16 KiB, and free of the code points below. All string fields are
plain text: authored Markdown and HTML are not supported, and the renderer
Expand Down
58 changes: 51 additions & 7 deletions internal/policy/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ type ThreatModelSection struct {
EvidenceStatuses []string `json:"evidence_statuses"`
ReferenceHosts []string `json:"reference_hosts"`
Coverage Coverage `json:"coverage"`
Limits Limits `json:"limits"`
Render Render `json:"render"`
}

Expand All @@ -92,13 +93,24 @@ type ThreatModelSection struct {
// declared was actually analysed; a consumer building a model incrementally
// can turn one off while the model is still being written.
type Coverage struct {
RequireAssetCoverage bool `json:"require_asset_coverage"`
RequireBoundaryCoverage bool `json:"require_boundary_coverage"`
RequireFlowCoverage bool `json:"require_flow_coverage"`
RequireEntryPointCoverage bool `json:"require_entry_point_coverage"`
RequireControlCoverage bool `json:"require_control_coverage"`
RequireRiskCoverage bool `json:"require_risk_coverage"`
RequireEvidencePerThreat bool `json:"require_evidence_per_threat"`
RequireAssetCoverage bool `json:"require_asset_coverage"`
RequireBoundaryCoverage bool `json:"require_boundary_coverage"`
RequireFlowCoverage bool `json:"require_flow_coverage"`
RequireEntryPointCoverage bool `json:"require_entry_point_coverage"`
RequireControlCoverage bool `json:"require_control_coverage"`
RequireRiskCoverage bool `json:"require_risk_coverage"`
RequireEvidencePerThreat bool `json:"require_evidence_per_threat"`
RequireCriticalityForEveryPriority bool `json:"require_criticality_for_every_priority"`
}

// Limits is the consumer's quantitative policy for the collections whose
// usefulness depends on how much of them there is. Omitting a member, or
// setting it to zero, disables that limit — the schema says what these
// collections are, and the project says how much of them it expects.
type Limits struct {
MinCriticalityExamples int `json:"min_criticality_examples"`
MinTopAbusePaths int `json:"min_top_abuse_paths"`
MaxTopAbusePaths int `json:"max_top_abuse_paths"`
}

// StandardSource declares where citations for one standard may point: either
Expand Down Expand Up @@ -197,6 +209,12 @@ func (c *Config) ThreatsPolicy() (threats.Policy, error) {
Controls: section.Coverage.RequireControlCoverage,
Risks: section.Coverage.RequireRiskCoverage,
Evidence: section.Coverage.RequireEvidencePerThreat,
Criticality: section.Coverage.RequireCriticalityForEveryPriority,
},
Limits: threats.Limits{
MinCriticalityExamples: section.Limits.MinCriticalityExamples,
MinTopAbusePaths: section.Limits.MinTopAbusePaths,
MaxTopAbusePaths: section.Limits.MaxTopAbusePaths,
},
}
return result, nil
Expand Down Expand Up @@ -366,9 +384,35 @@ func (c *Config) validateThreatModelSection(
seen[host] = struct{}{}
}

validateLimits(add, section.Limits)
validateRender(add, "threat_model.render", section.Render)
}

// validateLimits rejects negative bounds and a max below its min. A limit
// that can never be satisfied is a configuration error worth catching at
// load time rather than as a puzzling document rejection later.
func validateLimits(add func(location, format string, args ...any), limits Limits) {
for _, bound := range []struct {
name string
value int
}{
{"min_criticality_examples", limits.MinCriticalityExamples},
{"min_top_abuse_paths", limits.MinTopAbusePaths},
{"max_top_abuse_paths", limits.MaxTopAbusePaths},
} {
if bound.value < 0 {
add("threat_model.limits."+bound.name, "expected a non-negative integer")
}
}
if limits.MaxTopAbusePaths > 0 && limits.MinTopAbusePaths > limits.MaxTopAbusePaths {
add(
"threat_model.limits.max_top_abuse_paths",
"must not be smaller than min_top_abuse_paths (%d)",
limits.MinTopAbusePaths,
)
}
}

func (c *Config) compilePattern(errs *[]string, location, value string) *regexp.Regexp {
add := func(format string, args ...any) {
*errs = append(*errs, location+": "+fmt.Sprintf(format, args...))
Expand Down
Loading