fix: scope install security scan to deployable content - #2598
fix: scope install security scan to deployable content#2598Aryan Singh K. (aryansk) wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
This PR narrows the pre-deploy security scan to “deployable” package content by introducing a path filter in the security gate and defining deployable-path heuristics, with accompanying unit tests.
Changes:
- Add an optional
path_filtercallback toSecurityGate.scan_filesto skip scanning non-matching files. - Define
_is_deployable_source_pathand deployable path patterns used by the install pre-deploy scan. - Add unit tests covering deployable vs source-only paths and Windows path normalization.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| tests/unit/install/test_security_scan_scope.py | Adds tests for deployable-path detection logic and path normalization. |
| src/apm_cli/security/gate.py | Adds path_filter to optionally exclude files from scanning. |
| src/apm_cli/install/helpers/security_scan.py | Implements deployable path heuristics and wires filtering into the pre-deploy scan. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| path_filter=None, | ||
| ) -> ScanVerdict: | ||
| """Walk *root*, scan every regular file, return a verdict. |
| @@ -96,6 +97,8 @@ def scan_files( | |||
| if fpath.is_symlink(): | |||
| continue | |||
| rel = portable_relpath(fpath, root) | |||
| @@ -0,0 +1,29 @@ | |||
| from apm_cli.install.helpers.security_scan import _is_deployable_source_path | |||
| verdict = SecurityGate.scan_files( | ||
| install_path, | ||
| policy=BLOCK_POLICY, | ||
| force=force, | ||
| path_filter=_is_deployable_source_path, | ||
| ) |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/apm_cli/security/gate.py:90
- scan_files() docstring says "scan every regular file" / "All files are scanned" but the new path_filter can intentionally skip files. Updating the docstring to describe the filter contract (and that the filter receives the portable forward-slash relpath) will prevent API confusion for future callers.
"""Walk *root*, scan every regular file, return a verdict.
Symlinks are never followed (``followlinks=False``, ``is_symlink()``).
All files are scanned to produce a complete findings report.
"""
tests/unit/install/test_security_scan_scope.py:29
- These tests validate the path classifier, but they don't assert that SecurityGate.scan_files(..., path_filter=...) actually prevents a critical finding in a non-deployable file from blocking an install. Adding a regression test that creates both a deployable file and a source-only file with a critical hidden char and asserts the source-only file is excluded (via verdict.scanned_files and verdict.should_block) would protect the end-to-end behavior this PR relies on.
from apm_cli.install.helpers.security_scan import _is_deployable_source_path
def test_deployable_primitives_are_scanned():
deployable = [
"SKILL.md",
"plugin.json",
".apm/agents/reviewer.agent.md",
".github/instructions/security.instructions.md",
"skills/review/SKILL.md",
"hooks/hooks.json",
]
assert all(_is_deployable_source_path(path) for path in deployable)
def test_source_only_files_are_not_scanned_by_install_gate():
source_only = [
"src/DotnetInspector.HostileNameFixtures/HostileLiterals.cs",
"tests/fixtures/hostile-name.txt",
"docs/hostile-metadata.md",
"src/Program.cs",
]
assert not any(_is_deployable_source_path(path) for path in source_only)
def test_windows_paths_are_normalized():
assert _is_deployable_source_path(r".github\agents\reviewer.agent.md")
src/apm_cli/install/helpers/security_scan.py:49
- _is_deployable_source_path currently treats any path containing a deployable directory name anywhere in the path as deployable (e.g., any file under ".github"), which can still block installs due to findings in non-deployed areas like ".github/workflows" or nested source fixtures (e.g., "src/skills/..."), undermining the goal of scoping the install gate to deployable content only. Consider restricting the match to top-level deploy roots (".apm", ".github", "skills", "hooks", etc.) and, for ".apm"/".github", only allow known deployable subdirectories.
def _is_deployable_source_path(relative_path: str) -> bool:
"""Return whether a fetched source file belongs to deployable package content."""
parts = tuple(part for part in relative_path.replace("\\", "/").split("/") if part)
if not parts:
return False
if parts[-1] in _DEPLOYABLE_NAMES or parts[-1].endswith(_DEPLOYABLE_SUFFIXES):
return True
return any(part in _DEPLOYABLE_DIRS for part in parts[:-1])
APM Review Panel:
|
| Persona | B | R | N | Takeaway |
|---|---|---|---|---|
| Python Architect | 0 | 1 | 2 | Clean, well-scoped change. The filter pattern (pass a callable predicate into the scanner) is the correct minimal extension -- composition over subclassing. One type-annotation gap on the public API; otherwise architecturally sound. |
| CLI Logging Expert | 0 | 0 | 1 | Minor wording nit on the new tree-item note; no blocking issues with the CLI output changes. |
| DevX UX Expert | 0 | 1 | 2 | Blocked-install message regresses: noun label replaces actionable verb, and new cleanup note adds anxiety without guidance. Scope narrowing itself is fine but invisible. |
| Supply Chain Security Expert | 1 | 2 | 0 | Scan narrowing excludes bin/ and canvas paths that are still deployed -- creates executable bypass. Blocking. |
| OSS Growth Hacker | 0 | 0 | 1 | Good friction-removal for package authors; minor opportunity to convert the error path into a trust signal via docs link. |
| Doc Writer | 1 | 3 | 0 | 3 doc pages have stale scan-scope claims (blocking FAQ, recommended inline); CHANGELOG [Unreleased] missing fix entry. |
| Test Coverage Expert | 0 | 2 | 1 | Unit predicate tests present; no test proves path_filter is wired into the install pipeline or exercises the false-positive-avoidance end-to-end. |
| Performance Expert | 0 | 0 | 2 | Filter is O(depth) per file with frozenset lookups; no algorithmic regression. Adds <25ms for 10k files. Ship as-is. |
B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.
Top 5 follow-ups
- [Supply Chain Security Expert] (blocking-severity) Expand
_DEPLOYABLE_DIRSto includebin/,canvas/, and MCP-server paths -- everythingintegrate_package_primitivescan deploy --bin/,canvas/, and MCP paths are absent from the allowlist yet deployed; hostile executables bypass the scan entirely. - [Doc Writer] (blocking-severity) Update
docs/src/content/docs/enterprise/security.mdFAQ line 531 to say 'deployable primitive files' instead of 'all package source files' -- stale security claim in user-facing docs erodes enterprise trust. - [Test Coverage Expert] Add integration test proving
path_filteris wired into the install pipeline and that non-deployable hostile content is skipped -- missing wiring test on a secure-by-default surface; the filter could be silently disconnected in a future refactor with no signal. - [DevX UX Expert] Drop or rephrase 'failed install may remove this checkout' note to an actionable hint -- adds anxiety without guidance on a failure path where clarity is paramount.
- [Supply Chain Security Expert] Tighten directory-name matching to prevent over-broad ancestor matches (e.g.
vendor/test-data/hooks/payload.md) -- currentany(part in _DEPLOYABLE_DIRS ...)logic matches too broadly; reduces filter precision.
Architecture
classDiagram
direction LR
class SecurityGate {
<<Facade>>
+scan_files(root, policy, force, path_filter) ScanVerdict
+report(verdict, diagnostics, package, force)
}
class ScanPolicy {
<<ValueObject>>
+block_on_critical bool
}
class ScanVerdict {
<<ValueObject>>
+has_findings bool
+should_block bool
+findings list
}
class ContentScanner {
<<Utility>>
+scan_file(path) list
}
class DiagnosticCollector {
<<Collect-then-render>>
+add_diagnostic(diag)
}
SecurityGate ..> ScanPolicy : uses
SecurityGate ..> ScanVerdict : returns
SecurityGate ..> ContentScanner : delegates scanning
SecurityGate ..> DiagnosticCollector : reports to
note for SecurityGate "path_filter: Callable predicate\ninjected by caller to scope walk"
class SecurityGate:::touched
classDef touched fill:#fff3b0,stroke:#d47600
flowchart TD
A["apm install invoked"] --> B["_pre_deploy_security_scan(install_path)"]
B --> C["_is_deployable_source_path filter built"]
C --> D["SecurityGate.scan_files(root, path_filter=filter)"]
D --> E{"os.walk(root)"}
E --> F{"fpath.is_symlink?"}
F -->|yes| E
F -->|no| G{"path_filter(rel)?"}
G -->|no skip| E
G -->|yes| H["[I/O] ContentScanner.scan_file(fpath)"]
H --> I{"more files?"}
I -->|yes| E
I -->|no| J["return ScanVerdict"]
J --> K{"verdict.should_block?"}
K -->|yes| L["logger.error + return False"]
K -->|no| M["return True -- proceed with deploy"]
Recommendation
The pattern and predicate logic are sound, but the allowlist is incomplete on a security-critical surface: deployed executables (bin/, canvas/, MCP) bypass the narrowed scan. This is a regression from the prior full-tree scan. Expand _DEPLOYABLE_DIRS to be a strict superset of everything integrate_package_primitives deploys, fix the stale FAQ claim, and add one wiring integration test. Once those land, this is a clean ship with a good release-note story.
Full per-persona findings
Python Architect
- [recommended]
path_filterparameter lacks type annotation atsrc/apm_cli/security/gate.py:84
Thepath_filterparameter inSecurityGate.scan_filesis untyped (path_filter=None). All public APIs in APM should have type hints per code quality standards. This should bepath_filter: Callable[[str], bool] | None = Nonewith the appropriate import.
Suggested:path_filter: "Callable[[str], bool] | None" = None - [nit] Deployable vocabulary is a candidate for centralization
The_DEPLOYABLE_DIRS,_DEPLOYABLE_NAMES, and_DEPLOYABLE_SUFFIXESconstants define what constitutes 'deployable content' -- a concept that may be needed by other subsystems. If a second call site emerges, extract tocore/primitives.pyper the single-owner rule. Fine as-is for one call site. - [nit] Test imports a private function directly
Importing_is_deployable_source_pathcouples tests to an internal name. If this filter is considered a stable contract worth testing independently, consider making it public or testing indirectly through_pre_deploy_security_scan.
CLI Logging Expert
- [nit] New tree-item note is passive and non-actionable
The note about transaction cleanup is informational but gives the user nothing to act on. Consider shortening to|-- Checkout may be removed on rollbackto stay terse, or drop it entirely since --force is the real action.
DevX UX Expert
- [recommended] New 'failed install may remove this checkout' note is confusing and non-actionable at
src/apm_cli/install/helpers/security_scan.py:88
The user just got a security block. The next-action they need is 'inspect' or '--force'. Injecting a note about transaction cleanup introduces a concept (checkout lifecycle) they didn't ask about, creates anxiety ('will my files disappear?'), and doesn't tell them what to DO.
Suggested: Either drop the note entirely or rephrase: '|-- Hint: copy or inspect the checkout before retrying -- cleanup may remove it' - [nit] Rename 'Source checkout' back to 'Inspect source' for verb-led scannability at
src/apm_cli/install/helpers/security_scan.py:87
'Inspect source: ' tells the user what to do (verb) and where (path). 'Source checkout: ' is a noun-noun label that requires the user to infer the action. - [nit] Silent scope narrowing is invisible to users -- consider a verbose-mode breadcrumb
When the scan passes, no output tells the user what was actually scanned. A --verbose or debug log line like 'Security scan: 12 deployable files checked' would preserve trust.
Supply Chain Security Expert
- [blocking]
_DEPLOYABLE_DIRSomitsbin,canvas, and MCP-server paths -- deployed executables bypass security gate atsrc/apm_cli/install/helpers/security_scan.py:14
_DEPLOYABLE_DIRSomitsbin,canvas, and MCP-server paths, yetintegrate_package_primitivesdeploys executables frombin/, canvas extensions, and MCP configs. A malicious package can place a hostile script inbin/malware.sh-- it will NOT be scanned but WILL be deployed and potentially executed. The filter allowlist MUST be a superset of everythingintegrate_package_primitivescan deploy, or this creates a scan-bypass vector for the most dangerous artifact class (executables). - [recommended] Directory-name-only matching is over-broad and under-specific at
src/apm_cli/install/helpers/security_scan.py:44
any(part in _DEPLOYABLE_DIRS for part in parts[:-1])matches ANY ancestor directory segment, not just the package-root-relative first segment. A path likevendor/test-data/hooks/payload.mdpasses the filter even thoughhooks/is nested under unrelated directories. - [recommended]
path_filterlacks type annotation and contract documentation atsrc/apm_cli/security/gate.py:84
Addpath_filter: Callable[[str], bool] | None = Noneand a note that returning False SKIPS the file (fail-open semantics -- important to document for security-sensitive API).
OSS Growth Hacker
- [nit] Error message could link to docs explaining the security scan
A package author hitting a false-positive block during development has high frustration. Adding a docs URL in the CLI output converts that frustration moment into trust.
Auth Expert -- inactive
PR touches security_scan.py and gate.py for file-path filtering of deployable content; no auth, token, credential, or remote-host surface is affected.
Doc Writer
- [blocking] FAQ answer now incorrect: 'scans all package source files' at
docs/src/content/docs/enterprise/security.md:531
The FAQ answer 'APM scans all package source files before deployment' is now incorrect. After this PR, only deployable primitive files are scanned. Should say: 'APM scans deployable primitive files in each package before deployment.' - [recommended] Content scanning section implies all source files are scanned at
docs/src/content/docs/enterprise/security.md:153
The sentence implies full-repo scanning. After this PR the scan is scoped to deployable content. Adding 'deployable' before 'source files' would make it accurate. - [recommended] Install description says 'scans every primitive' -- acceptable but imprecise at
docs/src/content/docs/consumer/install-packages.md:8
Both the intro and pipeline step 3 are directionally correct but loose. Consider tightening to 'scans deployable primitives in each package'. - [recommended] Unreleased section missing entry for install scan scope fix at
CHANGELOG.md:8
The [Unreleased] block has no entry for this behavioral change. Users running apm install who previously saw false-positive scan blocks will notice the change. A Fixed entry closing [BUG] Install security scan flags hidden characters in files that are never deployed (repo-wide scan vs deployable primitives) #2490 belongs here.
Test Coverage Expert
- [recommended] No test asserts
path_filteris passed toSecurityGate.scan_files
The existing TestPreDeploySecurityScan tests all mockSecurityGate.scan_files, so they never verify thatpath_filter=_is_deployable_source_pathis actually wired in. If someone removes the path_filter kwarg, no test fails.
Proof (missing):tests/integration/test_install_marketplace_phase4w3.py::test_path_filter_wired_to_scan_files-- proves: The install pipeline actually passes the filter to the gate - [recommended] No integration test proves hostile content in non-deployable paths is skipped
No test exercises this end-to-end with real files on disk. The unit tests prove the predicate returns False for those paths, but the mocked integration tests never let a real file walk occur.
Proof (missing):tests/integration/test_install_marketplace_phase4w3.py::test_hostile_fixture_in_non_deployable_path_not_blocked-- proves: A package with hostile test fixtures installs without being blocked - [nit]
SecurityGate.scan_fileshas no unit test exercising thepath_filtercallback
gate.pynow acceptspath_filterand uses it at line 100.tests/unit/test_security_gate.pyhas no test for this new branch.
Proof (missing):tests/unit/test_security_gate.py::test_scan_files_respects_path_filter-- proves: SecurityGate.scan_files honors path_filter callback
Performance Expert
- [nit] Per-file filter cost is negligible
The filter does: one str.replace, one str.split, a tuple comprehension over path depth, a frozensetincheck (O(1)), and an endswith against a small tuple. For a 10k-file tree this adds ~15-25ms total -- well under noise. No O(n^2) risk. - [nit] Confirm collections are frozenset and tuple at module level
If_DEPLOYABLE_DIRSis a frozenset and_DEPLOYABLE_SUFFIXESis a tuple (both at module scope), the per-call cost is optimal. Both are already correct in the diff.
This panel is advisory. It does not block merge. Re-apply the
panel-review label after addressing feedback to re-run.
Closes #2490
Summary
SecurityGate.scan_filescallers to restrict which paths are scannedValidation
main