Skip to content

feat(skills): add TM7 generation and native validation harness to security-planning - #2574

Open
Bill Berry (WilliamBerryiii) wants to merge 20 commits into
mainfrom
feat/security-planning-tm7-generation
Open

feat(skills): add TM7 generation and native validation harness to security-planning#2574
Bill Berry (WilliamBerryiii) wants to merge 20 commits into
mainfrom
feat/security-planning-tm7-generation

Conversation

@WilliamBerryiii

Copy link
Copy Markdown
Member

Pull Request

Description

Adds deterministic Microsoft Threat Modeling Tool (.tm7) generation and a native validation harness to the security-planning skill, so a threat model authored as a declarative spec becomes a model TMT can open, laid out legibly, without hand-building diagrams.

The skill previously produced threat-model content as prose. It could not emit a .tm7 that opens cleanly, lays out readably, and can be validated without a human driving the tool by hand.

What lands:

  • Generation. generate_tm7.py builds a .tm7 from a YAML spec: one diagram surface per scope, trust-boundary rectangles containing their nodes, and data flows between them. generate_markdown.py renders the same spec as a markdown report. generate_tb7.py emits a template.
  • Layout. Text-aware node sizing, gutter spacing scaled to node size, and containment-preserving placement so nodes stay inside their owning trust boundary. Output is deterministic under input reordering: GUIDs derive from stable spec identifiers rather than randomness, and members serialize in canonical order.
  • Validation harness. validate_tm7_with_tmt.py drives native TMT through UI Automation, captures per-surface geometry metrics and pane-scoped screenshots, and writes a redacted evidence bundle with a schema-versioned manifest. Windows-only and opt-in; portable generation does not require TMT.
  • Layout overlay. A schema for reproducible manual layout adjustments, guarded by three fingerprints (spec, generator profile, surface identity) so a stale overlay is rejected rather than silently replayed onto a changed model. Overlays are always emitted approval_state: pending; no runtime path promotes them to approved.
  • Threat-model spec under version control. docs/planning/threat-models/hve-core-comprehensive.yaml is hve-core's own spec, previously living only in gitignored working state. Generated .tm7 and markdown outputs are build artifacts and are deliberately not committed.

Agent and instruction changes route the capability: the security planner and reviewer gain a TM7 workflow requiring explicit human confirmation before a generated model is treated as authored, plus an operator hands-off contract for the UI automation run. identity.instructions.md establishes the spec as source of truth with markdown rendered from it, so the model is edited at the spec and re-rendered rather than edited in the rendered artifact.

Related Issue(s)

Closes #2567

Type of Change

Select all that apply:

Code & Documentation:

  • Bug fix (non-breaking change fixing an issue)
  • New feature (non-breaking change adding functionality)
  • Breaking change (fix or feature causing existing functionality to change)
  • Documentation update

Infrastructure & Configuration:

  • GitHub Actions workflow
  • Linting configuration (markdown, PowerShell, etc.)
  • Security configuration
  • DevContainer configuration
  • Dependency update

AI Artifacts:

  • Reviewed contribution with hve-builder and addressed all actionable findings
  • Copilot instructions (.github/instructions/*.instructions.md)
  • Copilot prompt (.github/prompts/*.prompt.md)
  • Copilot agent (.github/agents/*.agent.md)
  • Copilot skill (.github/skills/*/SKILL.md)
  • Copilot hook (.github/hooks/*/*.json)
  • Eval spec added/updated for changed AI artifacts (evals/)

Note for AI Artifact Contributors:

  • Agents: Research, indexing/referencing other project (using standard VS Code GitHub Copilot/MCP tools), planning, and general implementation agents likely already exist. Review .github/agents/ before creating new ones.
  • Skills: Must include both bash and PowerShell scripts. See Skills.
  • Model Versions: Contributions MUST target models listed in the model catalog (scripts/linting/model-catalog.json) whose provider appears in providerAllowlist and whose status is ga or preview. Run npm run lint:models to validate references.
  • See Agents Not Accepted and Model Version Requirements.

Other:

  • Script/automation (.ps1, .sh, .py)
  • Other (please describe):

Sample Prompts (for AI Artifact Contributions)

User Request:

Generate a TM7 threat model from our threat-model spec.

Execution Flow:

  1. The security planner loads the security-planning skill and reads references/tm7-generation.md for the input schema and generation modes.
  2. The agent presents the input spec and asks the user to confirm scope and generation mode before running anything.
  3. generate_tm7.py reads the spec, resolves a template profile, builds the model, and applies layout: nodes sized to their text, placed inside their owning trust boundary, spaced by a gutter proportional to node size.
  4. The agent presents the generated model for explicit human confirmation. A generated model is never treated as authored or final without it.
  5. If the user requests native validation and is on Windows with the pinned TMT version, the agent states the operator hands-off contract, runs the harness, and reports the evidence bundle location.

Output Artifacts:

A .tm7 file (Microsoft SerializableModelData DataContract XML). First lines of a generated model:

<?xml version='1.0' encoding='utf-8'?>
<ThreatModel xmlns:i="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://schemas.datacontract.org/2004/07/ThreatModeling.Model">
  <DrawingSurfaceList>
    <DrawingSurfaceModel>
      <Header>System context and trust boundaries</Header>

A native harness run additionally produces an evidence bundle:

<evidence-dir>/
  manifest.json          schema-versioned run metadata and per-surface metrics
  status.json            exit state and last successful action
  action.log             redacted human-auditable action trail
  screenshots/           pane-scoped renders, one per surface
  uia/                   UI Automation traces
  iterations/00-baseline through iterations/03
  overlay.json           approval_state: pending

Success Indicators:

The model opens in TMT without template-upgrade or repair prompts. Nodes render inside their trust boundaries. Regenerating from an unchanged spec produces byte-identical output. The harness exits 0 with automated-ready-pending-human and gate_failure_count: 0.

Testing

  • uv run pytest tests/test_generate_tm7.py — 136 tests, zero failures. Covers containment (nodes stay inside their owning zone), determinism under input reordering, node overlap, rank ordering, and lane separation across 8 layout archetypes.
  • Six further pytest modules cover template generation, threat population, the threat DataContract, visual-feedback geometry and convergence, and the TMT harness.
  • Deserialize-Tm7.ps1 checks generated models against TMT assemblies for round-trip fidelity.
  • fuzz_harness.py provides an Atheris polyglot entry point for OSSF Scorecard fuzzing.
  • Native harness executed end to end against TMT 7.3.51110.1 on Windows: exit code 0, zero gate failures, per-surface metrics and screenshots captured across 8 surfaces.
  • uv run ruff check scripts tests — clean.

Checklist

Required Checks

  • Documentation is updated (if applicable)
  • Files follow existing naming conventions
  • Changes are backwards compatible (if applicable)
  • Tests added for new functionality (if applicable)

AI Artifact Contributions

  • Used hve-builder review mode to review contribution
  • Addressed all actionable findings from the hve-builder review
  • Verified contribution follows common standards and type-specific requirements

Required Local Checks

The following local-safe validation commands must pass before merging:

  • Local validation aggregate: npm run validate:local
  • Documentation validation (if docs changed): npm run validate:docs
  • Spell checking: npm run spell-check
  • Link validation: npm run lint:md-links

Security Considerations

  • This PR does not contain any sensitive or NDA information
  • Any new dependencies have been reviewed for security issues
  • Security-related scripts follow the principle of least privilege

This PR adds an executable runtime to a skill that previously shipped only reference material, so the security surface is new. SECURITY.md in the skill carries the full STRIDE model over three trust buckets (TMT process automation and UI Automation, local screenshots and evidence, overlay manifest and path handling) with risk ratings and eight open enterprise-readiness gaps.

Observed properties of the runtime:

  • No network egress. No socket, urllib, requests, or HTTP client imports anywhere in the skill.
  • XML parsing rejects <!DOCTYPE and <!ENTITY before parse and prefers defusedxml, closing the XXE path on specs, models, and templates.
  • Untrusted input is parsed with yaml.safe_load and json.load only. No eval, exec, pickle, or dynamic import.
  • The single subprocess launch targets a version-validated executable with stdout and stderr discarded.
  • Sensitive-key and sensitive-value redaction is applied to logs and manifests as defense in depth; no credentials are expected in specs or models.
  • Screenshots are cropped to the Diagram pane rather than the full window, reducing incidental capture of unrelated desktop content.
  • Overlays are emitted approval_state: pending and are never auto-promoted, so visual scores stay advisory rather than becoming silent approval.

Dependencies added: pyyaml==6.0.3 and defusedxml>=0.7 at runtime; pytest and ruff for development; atheris isolated in a fuzz group because macOS wheels are unavailable; pillow and pywinauto in a Windows-only group for UI Automation. uv.lock is committed so Dependabot resolves through the .github/skills/** glob. THIRD-PARTY-NOTICES records MIT attribution for the bundled Microsoft threat-modeling templates.

Additional Notes

The committed threat-model spec is marked DRAFT and has not had human security review. docs/planning/threat-models/README.md states this explicitly so neither the spec nor anything generated from it is mistaken for a reviewed artifact.

Generated models are intentionally not committed. A .tm7 for this repository is roughly 1.8 MB of XML, and any layout change rewrites geometry across every surface, so committing outputs would produce large unreviewable diffs on one-line source changes. The spec is the versioned source; README.md documents the regeneration command.

The native harness is Windows-only and opt-in. It requires TMT 7.3.51110.1 and takes exclusive control of mouse and keyboard for the duration of a run. Generation and markdown rendering are portable and require neither.

Known open items are tracked in the skill's SECURITY.md rather than left implicit. The ones most worth a reviewer's attention: Bundle.path() does not reject .. components, which is unreachable from current callers because they all pass hardcoded relative paths but could be reintroduced by a refactor; evidence bundles carry no integrity signature, so post-run tampering is not detectable without a comparison run; and the pinned TMT version is a module constant, so a tool upgrade requires a code change or the diagnostic override.

Two markdown link-check failures appear in npm run validate:local and are pre-existing on main, in files this PR does not touch: https://www.omg.org/spec/DMN/1.4/ (HTTP 522) in requirements-author and https://dev.azure.com (unreachable) in extension/PACKAGING.md. Verified by reproducing both on a clean checkout of origin/main.

…urity-planning

- generate deterministic .tm7 models from a declarative threat-model spec
- add containment-preserving layout with viewport-aware surface sizing
- add native TMT validation harness with per-surface metrics and screenshots
- add layout overlay schema for fingerprint-guarded manual adjustments
- commit hve-core threat-model spec under docs/planning/threat-models

Closes #2567

🔒 - Generated by Copilot
@WilliamBerryiii
Bill Berry (WilliamBerryiii) requested a review from a team as a code owner July 31, 2026 21:36
- Add tracked comprehensive-spec.yaml fixture; drop .copilot-tracking paths
- Render bases from the spec instead of an untracked 1.4 MB model
- Share one rendered base per module to cut redundant generation
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

OpenSSF Scorecard

Scorecard details
PackageVersionScoreDetails
pip/atheris 3.1.0 🟢 6.1
Details
CheckScoreReason
Packaging⚠️ -1packaging workflow not detected
Maintained⚠️ 23 commit(s) and 0 issue activity found in the last 90 days -- score normalized to 2
Token-Permissions🟢 10GitHub workflow tokens follow principle of least privilege
SAST⚠️ 0no SAST tool detected
Dangerous-Workflow🟢 10no dangerous workflow patterns detected
Binary-Artifacts🟢 10no binaries found in the repo
Code-Review🟢 7Found 23/30 approved changesets -- score normalized to 7
CII-Best-Practices⚠️ 0no effort to earn an OpenSSF best practices badge detected
Fuzzing🟢 10project is fuzzed
License🟢 10license file detected
Signed-Releases⚠️ -1no releases found
Pinned-Dependencies⚠️ 0dependency not pinned by hash detected -- score normalized to 0
Security-Policy🟢 10security policy file detected
Branch-Protection⚠️ 0branch protection not enabled on development/release branches
pip/colorama 0.4.6 UnknownUnknown
pip/comtypes 1.4.16 UnknownUnknown
pip/coverage 7.15.3 UnknownUnknown
pip/defusedxml 0.7.1 🟢 5.1
Details
CheckScoreReason
Dangerous-Workflow🟢 10no dangerous workflow patterns detected
Code-Review⚠️ 0Found 1/24 approved changesets -- score normalized to 0
Binary-Artifacts🟢 10no binaries found in the repo
Security-Policy🟢 10security policy file detected
Maintained⚠️ 00 commit(s) and 0 issue activity found in the last 90 days -- score normalized to 0
Token-Permissions🟢 10GitHub workflow tokens follow principle of least privilege
Pinned-Dependencies⚠️ 0dependency not pinned by hash detected -- score normalized to 0
CII-Best-Practices⚠️ 0no effort to earn an OpenSSF best practices badge detected
License🟢 9license file detected
Fuzzing🟢 10project is fuzzed
Packaging⚠️ -1packaging workflow not detected
Signed-Releases⚠️ -1no releases found
Branch-Protection⚠️ 0branch protection not enabled on development/release branches
SAST⚠️ 0SAST tool is not run on all commits -- score normalized to 0
pip/iniconfig 2.3.0 UnknownUnknown
pip/packaging 26.2 UnknownUnknown
pip/pillow 12.3.0 UnknownUnknown
pip/pluggy 1.6.0 UnknownUnknown
pip/pygments 2.20.0 UnknownUnknown
pip/pytest 9.1.1 UnknownUnknown
pip/pytest-cov 7.1.0 UnknownUnknown
pip/pywin32 312 UnknownUnknown
pip/pywinauto 0.6.9 🟢 4.3
Details
CheckScoreReason
Code-Review🟢 10all changesets reviewed
Maintained🟢 1030 commit(s) and 0 issue activity found in the last 90 days -- score normalized to 10
Packaging⚠️ -1packaging workflow not detected
Dangerous-Workflow🟢 10no dangerous workflow patterns detected
Token-Permissions⚠️ 0detected GitHub workflow tokens with excessive permissions
CII-Best-Practices⚠️ 0no effort to earn an OpenSSF best practices badge detected
Binary-Artifacts⚠️ 0binaries present in source code
Security-Policy⚠️ 0security policy file not detected
Pinned-Dependencies⚠️ 0dependency not pinned by hash detected -- score normalized to 0
Fuzzing⚠️ 0project is not fuzzed
License🟢 10license file detected
Signed-Releases⚠️ 0Project has not signed or included provenance with any releases.
Branch-Protection⚠️ -1internal error: error during branchesHandler.setup: internal error: some github tokens can't read classic branch protection rules: https://github.com/ossf/scorecard-action/blob/main/docs/authentication/fine-grained-auth-token.md
SAST🟢 7SAST tool detected but not run on all commits
pip/pyyaml 6.0.3 UnknownUnknown
pip/ruff 0.15.20 UnknownUnknown
pip/six 1.17.0 🟢 3.7
Details
CheckScoreReason
Maintained⚠️ 00 commit(s) and 0 issue activity found in the last 90 days -- score normalized to 0
Binary-Artifacts🟢 10no binaries found in the repo
Dangerous-Workflow🟢 10no dangerous workflow patterns detected
Code-Review⚠️ 2Found 7/30 approved changesets -- score normalized to 2
Packaging⚠️ -1packaging workflow not detected
Token-Permissions⚠️ 0detected GitHub workflow tokens with excessive permissions
Pinned-Dependencies⚠️ 0dependency not pinned by hash detected -- score normalized to 0
CII-Best-Practices⚠️ 0no effort to earn an OpenSSF best practices badge detected
Security-Policy⚠️ 0security policy file not detected
Fuzzing🟢 10project is fuzzed
License🟢 10license file detected
Signed-Releases⚠️ -1no releases found
Branch-Protection⚠️ 0branch protection not enabled on development/release branches
SAST⚠️ 0SAST tool is not run on all commits -- score normalized to 0
pip/tomli 2.4.1 UnknownUnknown

Scanned Files

  • .github/skills/project-planning/security-planning/uv.lock

@codecov-commenter

Codecov Comments Bot (codecov-commenter) commented Aug 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 76 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.74%. Comparing base (197afb8) to head (caf519a).

Files with missing lines Patch % Lines
...ning/security-planning/scripts/Deserialize-Tm7.ps1 0.00% 76 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #2574      +/-   ##
==========================================
- Coverage   83.10%   82.74%   -0.37%     
==========================================
  Files         164      152      -12     
  Lines       22254    22188      -66     
  Branches       29        0      -29     
==========================================
- Hits        18495    18359     -136     
- Misses       3756     3829      +73     
+ Partials        3        0       -3     
Flag Coverage Δ
docusaurus ?
pester 85.83% <0.00%> (-0.66%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...ning/security-planning/scripts/Deserialize-Tm7.ps1 0.00% <0.00%> (ø)

... and 14 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Eval Execution

⚠️ No eval summary was produced.

@github-actions github-actions Bot mentioned this pull request Aug 1, 2026
- Lock pytest-mock 3.15.1 from PyPI for mocker-based test coverage
- Match TM7 members by exact local name; Id and TypeId no longer collide
- Key authored-base index on SemanticId so relabelled flows resolve
- Remove the two unreferenced serializers
…alization

- Own slug derivation and mitigation text in tm7_threat_contract
- Attach resolved mitigations to generated threat instances
- Normalize Path and datetime in both fingerprint copies
…e loss

- Raise when a spec threat target_ref resolves to nothing
- Reject suppressing a node that still carries connectors
- Verify the KnowledgeBase placeholder before substitution
Layout could place a node outside its own trust boundary while still
reporting success. The only bound was a whole-surface check against twice
the viewport, which rejects gross overflow but says nothing about whether a
node stayed inside the zone that owns it. A moderately dense zone therefore
emitted a diagram whose trust boundaries no longer described the model.

Add `_assert_zone_containment` to fail closed when a node escapes its zone,
and correct the sizing defects the guard exposed:

- reserve the label band, padding, and node band that `_allocate` actually
  consumes instead of a flat 96 points
- honor measured subtree requirements when sizing the root canvas, which
  previously used only node-count and depth heuristics
- give each sibling zone its own requirement in `_pack_zone_rects` and share
  only the surplus, rather than splitting the parent evenly
- stop `max(120.0, ...)` floors from letting a content box extend past the
  zone that owns it, so an insufficient allocation reaches the existing
  fail-closed check
- clamp node width to its lane and keep the contextual lane inside the zone

The shipped `threat-model-spec-example.yaml` was rendering `ds-01` 22.8
points outside `tz-01`. Comprehensive-spec output is byte-identical, so the
sizing corrections are confined to zones that were previously mis-sized.

Refs finding 25
`write_tm7` used `Path.write_text`, which truncates the destination before
the new content lands. A failing write therefore left the previous model at
zero bytes rather than intact. Both writers now stage a sibling temporary
file, flush and fsync it, then move it into place with `os.replace`, and
unlink the temporary file if anything raises. The replace also swaps a
symlinked destination instead of writing through it to the link target.

`generate_tb7` additionally:

- derives threat metadatum identifiers from the property name instead of
  `uuid.uuid4()`, which contradicted the module's deterministic contract and
  made two runs over identical input produce non-comparable templates
- creates the output parent directory, so a nested destination no longer
  raises an unhandled FileNotFoundError that escapes the CLI as a traceback

The durability test injects failure with an unencodable lone surrogate
rather than by patching the write path, so it describes the contract rather
than the implementation.

Refs findings 21, 23, 65
`populate_tm7_threats` asserted a hardcoded 80 unique threat ids with no
override, so every internally consistent model that declared a different
number was rejected. The count is now an optional `expected_threat_count`
argument with a matching `--expected-threat-count` flag, defaulting to
accepting any consistent spec and failing only when the caller's own number
disagrees.

Also:

- catch `ThreatContractError` at the `generate_tm7` CLI boundary so an
  invalid threat state reports a concise message and a nonzero exit code
  instead of escaping as a traceback
- resolve markdown template profiles from the package root, matching
  `generate_tm7`; deriving the directory from the spec location let an
  external spec resolve a different profile than generation used
- emit completeness warnings through a module logger so importing callers
  can capture or silence them, with logging configured at the CLI boundary
  to preserve the operator-visible stderr output

`test_given_incomplete_spec_when_emit_then_ctm_warnings` now asserts log
records rather than captured stderr, because pytest's logging plugin
intercepts them. Operator-visible stderr is covered separately by a CLI test
that runs the real subprocess.

Refs findings 4, 19, 24, 45
`_validate_feedback_candidate` returned a hardcoded
`"semantic_regression": False`, and its only caller recomputed the value
solely when it was None. The gate was therefore dead code:
`_evaluate_semantic_regression` never ran in the production feedback path,
so a candidate that changed the model's semantic identity could still reach
`automated-ready-pending-human`.

The producer now returns None. It validates one candidate in isolation and
holds no baseline, so it cannot decide whether identity regressed; deferring
to the caller that owns the baseline makes the existing guard live rather
than adding a second evaluation path.

The new test patches the inner `_validate_candidate` seam rather than
`_validate_feedback_candidate`, so the real feedback-candidate body and the
real evaluator both execute. Reverting only the production change fails the
test at the status assertion, confirming it characterizes the defect rather
than the implementation.

Refs finding 2
`evaluate_convergence` returned the success stop reason whenever
`gate_failure_count == 0` and only checked `evidence_complete` afterwards, so
a run that captured nothing reported `automated-ready-pending-human` on the
strength of having observed no failures. The evidence check now runs first.

Also:

- catch `GenerationError` around candidate regeneration in the feedback loop,
  which previously escaped `run_harness` entirely and left no status.json
- drop the six constant-0.0 members from `score_surface_layout_candidate`.
  Each needs laid-out geometry that a candidate never carries, so they could
  not be computed and, being constant, could never order two candidates; the
  stop decision ran on a score blind to six of the eleven dimensions it named
- return `matches[0]` from `select_surface_tab`. The previous expression
  indexed `tabs` by `matches.index(matches[0])`, which is always 0, so on the
  raw-control path every surface resolved to the first tab and all captured
  evidence was attributed to it
- gate the overlay write on a successful outcome. A ready status without an
  overlay is downgraded rather than reported as success, and a stopped run no
  longer publishes an overlay accumulated from an earlier clean iteration

Refs findings 3, 20, 27, 29, 32
Executable discovery built roots from unvalidated environment variables,
applied no signature check, and selected `max(..., key=st_mtime)`. Newest
modification time is not a trust signal, so a decoy dropped into an allowed
root wins on timestamp alone. Roots must now be absolute, acceptance requires
a valid Authenticode signature naming CN=Microsoft Corporation, and selection
is deterministic over trusted candidates preferring the pinned version.

Redaction was inert on its real sink shapes. Measured against the previous
implementation, five of eight representative credential shapes survived. The
worst case was `Authorization: Bearer <jwt>`: the pattern's `\S+` consumed
only the word "Bearer" and published the JWT, so the output looked redacted
while leaking the secret. The key and value patterns now cover secret,
credential, account key, connection string, private key, and signature
shapes, the value run stops at a separator, a bare bearer or basic credential
is matched without a preceding key name, and any query carrying a sensitive
parameter is dropped whole.

Also:

- redact `write_csv_export` rows, the only sink that persisted verbatim
- require a `.tm7-harness-owned` marker before recursive deletion and report
  cleanup errors instead of suppressing them with `ignore_errors=True`
- require a complete replay invalidation block. The fingerprints were
  synthesized with `setdefault` from the same context they are validated
  against, so an overlay that omitted the block validated unconditionally;
  the evasion was deletion rather than forgery

`_authenticode_subject` tries PowerShell 7 before Windows PowerShell because
`Microsoft.PowerShell.Security` fails to load under the latter in some
environments. A signature that cannot be established is treated as untrusted,
never as a pass.

Refs findings 7, 8, 9, 38, 39
…loading

Each XML reader carried its own DTD and entity guard, and every copy scanned
raw bytes for `<!DOCTYPE`. That only matches UTF-8; the same document encoded
as UTF-16 interleaves NUL bytes, so the marker never appeared and the guard
silently passed. `generate_tb7` had no policy at all and rejected an undefined
entity only as an incidental ElementTree behavior.

All four readers now call `tm7_threat_contract.parse_hardened_xml_bytes`,
which decodes through the encodings XML permits before scanning, prefers
defusedxml, and converts parser and defusedxml failures alike into
`UnsafeXmlError` so no unsafe document escapes as a traceback.

`Deserialize-Tm7.ps1`:

- select the assembly directory by verifying it resolves the required type
  rather than by sort order. One ClickOnce payload directory carries only the
  local-storage assembly, and both share a timestamp, so the previous
  newest-write-time selection chose between them arbitrarily
- confine assembly resolution to the selected directory and reject names
  carrying path separators or traversal
- require absolute roots so an unset environment variable cannot contribute a
  directory beside the working directory
- narrow the relaunch from `-ExecutionPolicy Bypass` to `RemoteSigned`, which
  still enforces signature checks on files carrying mark-of-the-web

Also resolves two CodeQL "empty except" alerts in the fuzz harness by
explaining why a rejected payload is the expected outcome.

Refs findings 35, 36, 40
Dependency Review failed the security-planning skill's Windows-only group.
All four packages ship permissive licenses that already appear in
allow-licenses; the action cannot match them because the dependency graph
reports a compound expression or "unknown".

Verified against the installed wheels:

- pywinauto declares BSD-3-Clause AND LGPL-2.1-only AND LGPL-2.1-or-later.
  The distributed wheel is BSD-3-Clause only: METADATA declares
  "License: BSD 3-clause" with the OSI BSD classifier, the bundled LICENSE is
  the BSD 3-clause text, and the package contains no LGPL reference. The LGPL
  components are detected in the upstream source repository and are not
  redistributed. This matches the existing shapely entry, whose LGPL
  component likewise refers to material outside the distributed code.
- comtypes is MIT, pillow is MIT-CMU, pywin32 is PSF; each is already an
  allowed license type but reports "unknown" through the graph.

The harness is Windows-only, opt-in, and not distributed as a product;
portable TM7 generation requires none of these packages.
The fixtures README named an upstream repository and file but never said which
local fixture came from it, and claimed the copy was "stored unmodified". The
notices described the Microsoft threat-modeling templates as "reference-only".
Both claims were wrong.

Provenance was established by comparing every local .tm7 fixture against every
.tm7 in the upstream repository at a recorded revision. Exactly one matched:
tmt-reference.tm7 is gholliday/tm7-cli samples/demo.tm7 at
715954acc5b0a42386d3c0a3a42cdf35c5f41cfc, MIT licensed. It is not
byte-identical: three LF line breaks inside <b:string> elements are stored as
CRLF, which accounts for the entire 3-byte size delta. With whitespace
normalized the two files are byte-identical at 1190991 bytes, so the
difference is a checkout line-ending translation rather than an edit. The
other three fixtures match nothing upstream and are first-party.

default.tb7 and default-kb.xml are tracked and redistributed, and the
knowledge base is embedded into every generated model, so "reference-only"
understated the obligation. Both files are now named with SHA-256 digests.

assets/templates/LICENSE was missing the final period of the MIT text.

No fixture or template bytes changed; all six SHA-256 digests are identical
before and after.

Refs findings 15, 16, 48, 69, 72
The gap register used topic-prefixed IDs (G-EXEC, G-UIA, G-EVD, G-OVER,
G-PATH, G-TRUST) and bare severity words, both of which the skill security
model rules prohibit: IDs must be G-{TOKEN}-{N} with STRIDE-aligned tokens,
and the Severity column must carry a bare {Category}-{Level} token. IDs are
remapped to G-SPF-1, G-TAM-1, G-INF-1, G-TAM-2, G-TAM-3, G-EOP-1, G-REP-1,
and G-DOS-1, with severities restated as Spoofing-High, Tampering-Med,
InfoDisc-Med, EoP-Med, Repudiation-High, and DoS-Med. Every cross-reference
in the risk-rating tables is updated to match.

The summary claimed 6 open residual gaps while the register listed 8.

The model claimed "pane-scoped capture" in six places. The implementation
calls ImageGrab.grab(window=handle) against the Threat Modeling Tool window,
so capture is window-scoped: everything the tool displays is captured, not
just the diagram pane. The claims now state window-scoped behavior, and the
information-disclosure row is downgraded from "Mitigated" to "Partially
mitigated" because text redaction does not apply to image content.

Mitigation prose for executable trust and overlay replay is restated against
the controls implemented in P03: absolute installation roots with an
Authenticode publisher check and deterministic selection, and a required
complete invalidation fingerprint block.

Refs findings 12, 37, 41, 57

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.

Thanks for the thorough and well-documented work here — the portable-vs-Windows-only split, the human-confirmation gates, and the attribution effort all show real care. This review found two High-severity issues worth resolving before merge: validate_tm7_with_tmt.py has two XML entry points (_parse_xml and read_expected_surfaces) that were not migrated to the shared tm7_threat_contract.parse_hardened_xml_bytes hardening introduced in commit e6fe27d7, reproducing the same XXE-class bypass that commit set out to close everywhere. Separately, independent SHA-256 recomputation shows the digests recorded in THIRD-PARTY-NOTICES for default.tb7 and default-kb.xml don't match the committed bytes — worth a fix given this PR's stated goal of exact attribution.

A few Medium items round this out: an unanchored substring match in the Authenticode publisher check, three failing CI checks with no explanation in the PR description, and some duplicated helper functions worth consolidating into the existing shared tm7_threat_contract module. Full findings and suggested fixes are in the linked review. Some smaller ones, close when irrelevant.

Comment on lines +577 to +590
def _parse_xml(path: Path) -> ET.Element:
data = path.read_bytes()
if b"<!DOCTYPE" in data.upper() or b"<!ENTITY" in data.upper():
raise HarnessFailure(
"TM7 input contains DTD or entity declarations",
EXIT_ERROR,
)
try:
if DefusedET is not None:
return DefusedET.fromstring(data)
return ET.fromstring(data)
except ET.ParseError as exc:
raise HarnessFailure(f"Unable to parse TM7 input: {exc}", EXIT_ERROR) from exc

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.

[Security/Standards] High — unmigrated XML hardening (CWE-611)

_parse_xml guards DOCTYPE/ENTITY with a raw byte scan (b"<!DOCTYPE" in data.upper()), which only matches UTF-8-family encodings. Commit e6fe27d7 introduced tm7_threat_contract.parse_hardened_xml_bytes (which decodes through every encoding XML permits before scanning) and migrated generate_tm7.py, generate_tb7.py, and populate_tm7_threats.py onto it, but this reader was left on the pre-fix check. A UTF-16-encoded TM7 with a DOCTYPE/ENTITY bypasses this guard; if defusedxml is unavailable, ET.fromstring resolves it (XXE).

Suggested fix:

import tm7_threat_contract

def _parse_xml(path: Path) -> ET.Element:
    try:
        return tm7_threat_contract.parse_hardened_xml_bytes(path.read_bytes())
    except tm7_threat_contract.UnsafeXmlError as exc:
        raise HarnessFailure(f"Unable to parse TM7 input: {exc}", EXIT_ERROR) from exc

Comment on lines +1078 to +1081
def read_expected_surfaces(model_path: Path) -> list[SurfaceDescriptor]:
"""Parse drawing surface descriptors from a TM7 model."""
parser = DefusedET.parse if DefusedET is not None else ET.parse
tree = parser(model_path)

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.

[Security] High — no DTD/entity guard at all on the ET.parse fallback (CWE-611)

read_expected_surfaces picks DefusedET.parse if DefusedET is not None else ET.parse with no pre-check before parsing. If defusedxml is unavailable, any TM7 model handed to the validator is parsed with plain ElementTree, fully exposed to XXE-based file disclosure and entity-expansion DoS.

Suggested fix:

def read_expected_surfaces(model_path: Path) -> list[SurfaceDescriptor]:
    """Parse drawing surface descriptors from a TM7 model."""
    try:
        root = tm7_threat_contract.parse_hardened_xml_bytes(model_path.read_bytes())
    except tm7_threat_contract.UnsafeXmlError as exc:
        raise HarnessFailure(f"Unable to parse TM7 model: {exc}", EXIT_ERROR) from exc

Comment on lines +481 to +494
def is_trusted_tmt_executable(path: Path) -> bool:
"""Return True only for a validly signed executable from the pinned publisher.

Newest modification time is not a trust signal. A decoy dropped into an
otherwise allowed root would win on mtime alone, so acceptance requires a
valid Authenticode signature naming the accepted publisher.
"""
signature = _authenticode_subject(path)
if signature is None:
return False
status, subject = signature
if status != "Valid":
return False
return ACCEPTED_PUBLISHER_CN.lower() in subject.lower()

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.

[Security] Medium — unanchored substring match on Authenticode Subject (CWE-295)

is_trusted_tmt_executable checks ACCEPTED_PUBLISHER_CN.lower() in subject.lower(), an unanchored substring match against the whole raw Subject DN rather than a parsed CN comparison. A validly-signed executable whose Subject DN contains the literal substring anywhere (e.g. embedded in another RDN's value) passes even if its actual leaf CN differs.

Suggested fix (derives the expected CN from the existing ACCEPTED_PUBLISHER_CN constant instead of a second hardcoded literal):

import re

_CN_RDN = re.compile(r"(?:^|,)\s*CN=([^,]+)", re.IGNORECASE)

def is_trusted_tmt_executable(path: Path) -> bool:
    signature = _authenticode_subject(path)
    if signature is None:
        return False
    status, subject = signature
    if status != "Valid":
        return False
    match = _CN_RDN.search(subject)
    if match is None:
        return False
    accepted_cn = ACCEPTED_PUBLISHER_CN.split("=", 1)[-1]
    return match.group(1).strip().lower() == accepted_cn.lower()

Comment on lines +128 to +141
def _normalize_text(value: Any) -> str:
return "" if value is None else str(value).strip()


def _coerce_list(value: Any) -> list[Any]:
if value is None:
return []
return value if isinstance(value, list) else [value]


def _local_name(tag: str) -> str:
return tag.rsplit("}", 1)[-1]


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.

[Standards] Medium — duplicated helpers across five scripts

_local_name, _normalize_text, _coerce_list, and _make_guid are each redefined in generate_tb7.py, generate_tm7.py, and/or populate_tm7_threats.py instead of importing the copies already living here. Since three of those files already import from tm7_threat_contract, centralizing these small helpers is low-risk.

return payload


def validate_layout_overlay(overlay: dict[str, Any], context: OverlayContext) -> None:

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.

[Standards] Medium — oversized function (~459 lines, through line 936)

validate_layout_overlay validates schema shape, top-level keys, schema_version, per-rule geometry, identity references, and the invalidation-fingerprint block all in one function body, which is hard to unit-test or review for completeness.

Suggested fix: split into focused helpers such as _validate_overlay_shape, _validate_overlay_rules, _validate_overlay_identities, and _validate_overlay_invalidation, called in sequence from a thin orchestrator.

Comment on lines 1 to 5
---
name: security-planning
description: Security planning reference set for operational buckets, STRIDE analysis, standards mapping, NIST control families, and backlog scaffolding.
description: Security planning reference set for operational buckets, STRIDE analysis, standards mapping, NIST control families, backlog scaffolding, and deterministic TM7 (.tm7) plus markdown dual-output generation.
license: MIT
user-invocable: true

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.

[Standards] Low — missing compatibility frontmatter

scripts/linting/schemas/skill-frontmatter.schema.json defines compatibility as an established optional field, and 15 other skills already set it. This skill's body documents that the native TMT feedback-loop workflow requires a Windows desktop session and UI Automation access, while the rest of the skill (STRIDE analysis, portable .tm7/markdown generation) is OS-independent — worth surfacing in frontmatter, e.g. compatibility: Windows-only for the native TMT validation/feedback workflow (TM7 generation and markdown output are OS-independent).

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.

LGTM aside from the points mentioned by Katrien De Graeve (@katriendg)

- confine every evidence path and route all sinks through redaction
- refuse screenshot capture without window isolation; fail closed in strict mode
- map every feedback stop reason explicitly; add harness-error fallback
- serialize ET namespace mutation behind one locked boundary
- reconcile security model, exit codes, and shipped docs with the runtime

🔒 - Generated by Copilot
…g-tm7-generation

# Conflicts:
#	collections/hve-core-all.collection.md
#	collections/project-planning.collection.md
#	collections/security.collection.md
#	docs/reference/skills/README.md
#	plugins/hve-core-all/README.md
#	plugins/project-planning/README.md
#	plugins/security/README.md
- declare Windows and pinned TMT prerequisites on the security-planning skill
- regenerate the skill index entry and corrected skill count

🔒 - Generated by Copilot
raise RuntimeError("write failed")

# Assert
assert dict(ET._namespace_map) == caller_namespace_registry
try:
for _ in range(25):
outputs.append(_serialize_probe_root())
except BaseException as exc: # pragma: no cover - reported below
from typing import Any
from xml.etree import ElementTree as ET

import tm7_threat_contract
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.

feat(skills): TM7 generation and native validation harness for security-planning

5 participants