diff --git a/CHANGELOG.md b/CHANGELOG.md index e6e1f56..b51b024 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,11 @@ All notable changes to this project are documented here. The format follows - Documentation site with screenshots, pre-commit hooks, a Docker image, a GitHub Action and release automation (`Prepare release` workflow, PyPI publishing, action repository sync). +- Framework packs: Django, FastAPI, Flask, NestJS, Spring Boot, ASP.NET Core, Terraform and + CloudFormation, with detectors, 20 new rules (SH014-SH020, SH106-SH109, SH206-SH208, SH303, + SH401-SH405), fixtures and docs. SH001-SH003 now cover these stacks too, and `devshield fix` + creates starter CI workflows for Python, Java and .NET projects. + ### Changed - Scans of large repositories are much faster: files that cannot match a rule are ruled out with diff --git a/README.md b/README.md index 218e238..c9a9020 100644 --- a/README.md +++ b/README.md @@ -465,6 +465,25 @@ auto_fix: ``` +# Supported frameworks + +| Stack | Detected from | Highlights | +|-------|---------------|------------| +| Laravel | `artisan`, `laravel/framework` | debug mode, CORS, cookies, rate limiting | +| Node, Express | `package.json` | CORS, headers, JWT, sessions, dynamic code | +| React, Next.js | `package.json` | XSS sinks, secrets in the bundle, CSP, bundle size | +| NestJS | `@nestjs/core` | helmet, ValidationPipe, CORS | +| Django | `django`, `manage.py` | `DEBUG`, `ALLOWED_HOSTS`, `SECRET_KEY`, `SECURE_*`, CSRF, cookies | +| FastAPI | `fastapi` | CORS, `/docs` exposure, secrets, SQL injection | +| Flask | `flask` | `debug=True`, `SECRET_KEY`, cookies, CSRF, Flask-CORS | +| Spring Boot | `pom.xml`, Gradle | Actuator, `application.properties` secrets, CSRF, `permitAll`, `ddl-auto` | +| ASP.NET Core | `Microsoft.NET.Sdk.Web` | developer exception page, `appsettings.json` secrets, CORS, Swagger | +| Terraform, CloudFormation | `*.tf`, templates | public S3, IAM `*`, open security groups, unencrypted or public databases | +| Docker, GitHub Actions | `Dockerfile`, workflows | root user, pinned actions, token permissions | + +See [docs/frameworks.md](docs/frameworks.md) for the rules that apply to each stack. + + # Rule Categories | Rules | Category | @@ -473,6 +492,7 @@ auto_fix: | SH100–SH199 | Authentication | | SH200–SH299 | Secrets | | SH300–SH399 | Configuration | +| SH400–SH499 | Cloud and infrastructure as code | | PERF001–PERF099 | Performance | | PERF100–PERF199 | Docker | | PERF200–PERF299 | Frontend | diff --git a/devshield/core/context.py b/devshield/core/context.py index 551579f..0940a52 100644 --- a/devshield/core/context.py +++ b/devshield/core/context.py @@ -1,5 +1,6 @@ from __future__ import annotations +import ast import json import os import re @@ -14,19 +15,36 @@ from devshield.core.git import tracked_files from devshield.core.models import DetectedFramework, Framework from devshield.core.parsers import ( + ConfigEntry, EnvEntry, Instruction, + flatten_json, + flatten_yaml, json_line_map, + load_template, parse_dockerfile, parse_dotenv, + parse_hcl, + parse_module, + parse_pipfile, + parse_properties, + parse_pyproject, + parse_requirements_txt, yaml_line_map, ) +from devshield.core.parsers.hcl import HclBlock DEFAULT_EXCLUDES = frozenset( { + ".eggs", ".git", + ".gradle", + ".mypy_cache", ".next", ".nuxt", + ".pytest_cache", + ".ruff_cache", + ".terraform", ".tox", ".venv", "__pycache__", @@ -34,6 +52,8 @@ "coverage", "dist", "node_modules", + "obj", + "target", "vendor", "venv", } @@ -43,6 +63,12 @@ COMPOSE_PATTERNS = ("docker-compose*.yml", "docker-compose*.yaml", "compose.yml", "compose.yaml") WORKFLOW_PATTERNS = (".github/workflows/*.yml", ".github/workflows/*.yaml") SOURCE_PATTERNS = ("*.js", "*.jsx", "*.ts", "*.tsx", "*.mjs", "*.cjs") +PYTHON_PATTERNS = ("*.py",) +JAVA_PATTERNS = ("*.java", "*.kt") +CSHARP_PATTERNS = ("*.cs",) +LOCKFILE_NAMES = frozenset({"package-lock.json", "composer.lock", "yarn.lock", "pnpm-lock.yaml"}) +TEMPLATE_MAX_BYTES = 512 * 1024 +REQUIREMENTS_PATTERNS = ("requirements*.txt", "requirements/*.txt") EXAMPLE_SUFFIXES = (".example", ".sample", ".template", ".dist", ".defaults") TEST_DIRS = frozenset({"test", "tests", "__tests__", "spec", "e2e", "cypress", "fixtures"}) @@ -61,8 +87,14 @@ def _is_example(name: str) -> bool: def is_test_path(rel: str) -> bool: *directories, name = rel.split("/") - return bool(TEST_DIRS.intersection(directories)) or bool( - re.search(r"\.(?:test|spec)\.|_test\.|^test_", name) + return ( + bool(TEST_DIRS.intersection(directories)) + or bool( + re.search( + r"\.(?:test|spec)\.|_test\.|^test_|^conftest\.py$|Tests?\.(?:cs|java|kt)$", name + ) + ) + or any(d.endswith((".Tests", ".UnitTests", ".IntegrationTests")) for d in directories) ) @@ -82,6 +114,7 @@ def __init__( self._resolved: dict[str, Path | None] = {} self._listed: frozenset[str] | None = None self._ignored: dict[str, bool] = {} + self._ast: dict[str, ast.Module | None] = {} self._line_maps: dict[str, dict[tuple[str | int, ...], int]] = {} @cached_property @@ -200,6 +233,67 @@ def _env_files(self, *, example: bool) -> list[str]: def source_files(self) -> list[str]: return [f for f in self.match(*SOURCE_PATTERNS) if not is_test_path(f)] + @cached_property + def python_files(self) -> list[str]: + return [f for f in self.match(*PYTHON_PATTERNS) if not is_test_path(f)] + + @cached_property + def java_files(self) -> list[str]: + return [f for f in self.match(*JAVA_PATTERNS) if not is_test_path(f)] + + @cached_property + def csharp_files(self) -> list[str]: + return [f for f in self.match(*CSHARP_PATTERNS) if not is_test_path(f)] + + @cached_property + def python_dependencies(self) -> dict[str, str]: + found: dict[str, str] = {} + for file in self.match(*REQUIREMENTS_PATTERNS): + found.update(parse_requirements_txt(self.read_text(file) or "")) + for file in self.match("pyproject.toml"): + found.update(parse_pyproject(self.read_text(file) or "")) + for file in self.match("Pipfile"): + found.update(parse_pipfile(self.read_text(file) or "")) + return found + + def pyast(self, rel: str) -> ast.Module | None: + if rel not in self._ast: + self._ast[rel] = parse_module(self.read_text(rel) or "") + return self._ast[rel] + + @cached_property + def terraform_files(self) -> list[str]: + return self.match("*.tf") + + def terraform(self, rel: str) -> list[HclBlock]: + return parse_hcl(self.read_text(rel) or "") + + @cached_property + def cloudformation_files(self) -> list[str]: + workflows = set(self.workflow_files) + found: list[str] = [] + for file in self.match("*.yml", "*.yaml", "*.json", "*.template"): + name = file.rsplit("/", 1)[-1] + if file in workflows or name in LOCKFILE_NAMES or self.size(file) > TEMPLATE_MAX_BYTES: + continue + text = self.read_text(file) or "" + if "AWS::" in text and "Resources" in text: + found.append(file) + return found + + def template(self, rel: str) -> dict[str, Any] | None: + """A parsed CloudFormation template (YAML with tags, or JSON).""" + return load_template(self.read_text(rel) or "", is_json=rel.endswith(".json")) + + def config_entries(self, rel: str) -> list[ConfigEntry]: + """Dotted key/value pairs of a .properties, YAML or JSON config file.""" + text = self.read_text(rel) or "" + if rel.endswith(".properties"): + return parse_properties(text) + if rel.endswith((".yml", ".yaml")): + return flatten_yaml(text) + return flatten_json(text) if rel.endswith(".json") else [] + @cached_property def tracked(self) -> frozenset[str] | None: return tracked_files(self.root) @@ -262,6 +356,10 @@ def frameworks(self) -> tuple[DetectedFramework, ...]: def has_framework(self, framework: Framework) -> bool: return any(d.framework == framework for d in self.frameworks) + def has_framework_any(self, frameworks: Iterable[Framework]) -> bool: + wanted = set(frameworks) + return any(d.framework in wanted for d in self.frameworks) + def _join(parent: str, name: str) -> str: return f"{parent}/{name}" if parent else name diff --git a/devshield/core/detect.py b/devshield/core/detect.py index 8f090cd..3d022c6 100644 --- a/devshield/core/detect.py +++ b/devshield/core/detect.py @@ -1,9 +1,11 @@ from __future__ import annotations +import re from collections.abc import Callable from typing import TYPE_CHECKING from devshield.core.models import Confidence, DetectedFramework, Framework +from devshield.core.search import search if TYPE_CHECKING: from devshield.core.context import ProjectContext @@ -43,6 +45,59 @@ def _node(ctx: ProjectContext) -> DetectedFramework | None: return _detected(Framework.NODE, ["package.json"] if ctx.exists("package.json") else []) +def _python_framework(framework: Framework, package: str, imports: str) -> Detector: + pattern = re.compile(rf"^\s*(?:from|import)\s+{imports}\b") + + def detect(ctx: ProjectContext) -> DetectedFramework | None: + evidence = [] + if package in ctx.python_dependencies: + evidence.append(f"Python dependency {package}") + hit = next(search(ctx, ctx.python_files, pattern), None) + if hit: + evidence.append(f"{hit.file} imports {imports}") + return _detected(framework, evidence, high_at=2) + + return detect + + +def _django(ctx: ProjectContext) -> DetectedFramework | None: + evidence = [] + if "django" in ctx.python_dependencies: + evidence.append("Python dependency django") + if "DJANGO_SETTINGS_MODULE" in (ctx.read_text("manage.py") or ""): + evidence.append("manage.py") + return _detected(Framework.DJANGO, evidence, high_at=2) + + +def _spring(ctx: ProjectContext) -> DetectedFramework | None: + evidence = [] + if "spring-boot" in (ctx.read_text("pom.xml") or ""): + evidence.append("pom.xml uses spring-boot") + for gradle in ("build.gradle", "build.gradle.kts"): + text = ctx.read_text(gradle) or "" + if "org.springframework.boot" in text or "spring-boot-starter" in text: + evidence.append(f"{gradle} uses Spring Boot") + return _detected(Framework.SPRING, evidence) + + +def _aspnet(ctx: ProjectContext) -> DetectedFramework | None: + evidence = [ + f + for f in ctx.match("*.csproj") + if "Microsoft.NET.Sdk.Web" in (ctx.read_text(f) or "") + or "Microsoft.AspNetCore" in (ctx.read_text(f) or "") + ] + return _detected(Framework.ASPNET, evidence) + + +def _terraform(ctx: ProjectContext) -> DetectedFramework | None: + return _detected(Framework.TERRAFORM, ctx.terraform_files[:3]) + + +def _cloudformation(ctx: ProjectContext) -> DetectedFramework | None: + return _detected(Framework.CLOUDFORMATION, ctx.cloudformation_files[:3]) + + def _docker(ctx: ProjectContext) -> DetectedFramework | None: return _detected(Framework.DOCKER, [*ctx.dockerfiles, *ctx.compose_files]) @@ -57,6 +112,14 @@ def _github_actions(ctx: ProjectContext) -> DetectedFramework | None: _node_package(Framework.EXPRESS, "express"), _node_package(Framework.REACT, "react"), _node_package(Framework.NEXTJS, "next"), + _node_package(Framework.NESTJS, "@nestjs/core"), + _django, + _python_framework(Framework.FASTAPI, "fastapi", "fastapi"), + _python_framework(Framework.FLASK, "flask", "flask"), + _spring, + _aspnet, + _terraform, + _cloudformation, _docker, _github_actions, ) diff --git a/devshield/core/models.py b/devshield/core/models.py index 7f181cc..4bce6d8 100644 --- a/devshield/core/models.py +++ b/devshield/core/models.py @@ -68,6 +68,14 @@ class Framework(StrEnum): NEXTJS = "nextjs" DOCKER = "docker" GITHUB_ACTIONS = "github_actions" + DJANGO = "django" + FASTAPI = "fastapi" + FLASK = "flask" + NESTJS = "nestjs" + SPRING = "spring" + ASPNET = "aspnet" + TERRAFORM = "terraform" + CLOUDFORMATION = "cloudformation" class Confidence(StrEnum): diff --git a/devshield/core/parsers/__init__.py b/devshield/core/parsers/__init__.py index 16085f6..464dfcc 100644 --- a/devshield/core/parsers/__init__.py +++ b/devshield/core/parsers/__init__.py @@ -1,3 +1,10 @@ +from devshield.core.parsers.appconfig import ( + ConfigEntry, + flatten_json, + flatten_yaml, + parse_properties, +) +from devshield.core.parsers.cloudformation import load_template from devshield.core.parsers.dockerfile import ( Instruction, effective_user, @@ -7,21 +14,53 @@ stages, ) from devshield.core.parsers.dotenv import EnvEntry, parse_dotenv +from devshield.core.parsers.hcl import HclBlock, parse_hcl from devshield.core.parsers.php import PhpEnv, PhpExpr, parse_php_config +from devshield.core.parsers.pyast import ( + MISSING, + literal, + module_assignments, + parse_module, + secret_assignments, + string_literals, +) +from devshield.core.parsers.pythondeps import ( + normalize, + parse_pipfile, + parse_pyproject, + parse_requirements_txt, +) from devshield.core.parsers.structured import json_line_map, yaml_line_map __all__ = [ + "MISSING", + "ConfigEntry", "EnvEntry", + "HclBlock", "Instruction", "PhpEnv", "PhpExpr", "effective_user", "final_stage", + "flatten_json", + "flatten_yaml", "from_parts", "json_line_map", + "literal", + "load_template", + "module_assignments", + "normalize", "parse_dockerfile", "parse_dotenv", + "parse_hcl", + "parse_module", "parse_php_config", + "parse_pipfile", + "parse_properties", + "parse_pyproject", + "parse_requirements_txt", + "secret_assignments", "stages", + "string_literals", "yaml_line_map", ] diff --git a/devshield/core/parsers/appconfig.py b/devshield/core/parsers/appconfig.py new file mode 100644 index 0000000..664f269 --- /dev/null +++ b/devshield/core/parsers/appconfig.py @@ -0,0 +1,91 @@ +"""Flatten application config files (.properties, YAML, JSON) into dotted key/value entries.""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from typing import Any + +import yaml + +from devshield.core.parsers.structured import json_line_map + +_PROPERTY = re.compile(r"^\s*([^\s=:#!][^\s=:]*)\s*[=:]\s*(.*?)\s*$") + + +@dataclass(frozen=True) +class ConfigEntry: + key: str + value: str + line: int + + +def parse_properties(text: str) -> list[ConfigEntry]: + entries: list[ConfigEntry] = [] + for number, raw in enumerate(text.splitlines(), start=1): + stripped = raw.strip() + if not stripped or stripped.startswith(("#", "!")): + continue + match = _PROPERTY.match(raw) + if match: + entries.append(ConfigEntry(match.group(1), match.group(2), number)) + return entries + + +def flatten_yaml(text: str) -> list[ConfigEntry]: + entries: list[ConfigEntry] = [] + try: + for document in yaml.compose_all(text, Loader=yaml.SafeLoader): + if document is not None: + _walk_yaml(document, "", entries) + except yaml.YAMLError: + return [] + return entries + + +def _walk_yaml(node: yaml.Node, prefix: str, out: list[ConfigEntry]) -> None: + if isinstance(node, yaml.MappingNode): + for key_node, value_node in node.value: + if not isinstance(key_node, yaml.ScalarNode): + continue + key = f"{prefix}.{key_node.value}" if prefix else str(key_node.value) + if isinstance(value_node, yaml.ScalarNode): + out.append(ConfigEntry(key, str(value_node.value), key_node.start_mark.line + 1)) + else: + _walk_yaml(value_node, key, out) + elif isinstance(node, yaml.SequenceNode): + for index, item in enumerate(node.value): + key = f"{prefix}[{index}]" + if isinstance(item, yaml.ScalarNode): + out.append(ConfigEntry(key, str(item.value), item.start_mark.line + 1)) + else: + _walk_yaml(item, key, out) + + +def flatten_json(text: str) -> list[ConfigEntry]: + try: + data = json.loads(text) + except ValueError: + return [] + lines = json_line_map(text) + entries: list[ConfigEntry] = [] + _walk_json(data, (), lines, entries) + return entries + + +def _walk_json( + value: Any, path: tuple[str | int, ...], lines: dict[Any, int], out: list[ConfigEntry] +) -> None: + if isinstance(value, dict): + for key, child in value.items(): + _walk_json(child, (*path, key), lines, out) + elif isinstance(value, list): + for index, child in enumerate(value): + _walk_json(child, (*path, index), lines, out) + elif path: + key = ":".join(str(p) for p in path) + text = ( + "" if value is None else str(value).lower() if isinstance(value, bool) else str(value) + ) + out.append(ConfigEntry(key, text, lines.get(path, 1))) diff --git a/devshield/core/parsers/cloudformation.py b/devshield/core/parsers/cloudformation.py new file mode 100644 index 0000000..3ff21eb --- /dev/null +++ b/devshield/core/parsers/cloudformation.py @@ -0,0 +1,31 @@ +"""Load CloudFormation templates, whose YAML tags (!Ref, !Sub) SafeLoader rejects.""" + +from __future__ import annotations + +import json +from typing import Any + +import yaml + + +class _Loader(yaml.SafeLoader): + pass + + +def _any_tag(loader: yaml.SafeLoader, suffix: str, node: yaml.Node) -> Any: + if isinstance(node, yaml.ScalarNode): + return loader.construct_scalar(node) + if isinstance(node, yaml.SequenceNode): + return loader.construct_sequence(node, deep=True) + return loader.construct_mapping(node, deep=True) # type: ignore[arg-type] + + +_Loader.add_multi_constructor("!", _any_tag) + + +def load_template(text: str, is_json: bool = False) -> dict[str, Any] | None: + try: + data = json.loads(text) if is_json else yaml.load(text, Loader=_Loader) # noqa: S506 + except (ValueError, yaml.YAMLError): + return None + return data if isinstance(data, dict) else None diff --git a/devshield/core/parsers/hcl.py b/devshield/core/parsers/hcl.py new file mode 100644 index 0000000..9aa678d --- /dev/null +++ b/devshield/core/parsers/hcl.py @@ -0,0 +1,130 @@ +"""A small reader for Terraform (HCL) files: resource blocks, their attributes and nested blocks. + +It is not a full HCL parser. It understands comments, quoted strings, heredocs and nested braces, +which is enough to read the settings that rules need. Expressions are kept as raw text. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field + +_HEADER = re.compile(r'^\s*(resource|data|module|provider)\s+((?:"[^"]*"\s*)+|[\w-]+\s*)\{', re.M) +_BLOCK = re.compile(r'^\s*([\w-]+)((?:\s+"[^"]*")*)\s*\{\s*$') +_ATTRIBUTE = re.compile(r"^\s*([\w-]+)\s*=\s*(.*)$") +_HEREDOC = re.compile(r"<<-?\s*([A-Za-z_][\w]*)\s*$") + + +@dataclass +class HclBlock: + kind: str + labels: tuple[str, ...] + line: int + attributes: dict[str, tuple[str, int]] = field(default_factory=dict) + blocks: list[HclBlock] = field(default_factory=list) + + @property + def type(self) -> str: + return self.labels[0] if self.labels else "" + + def value(self, name: str) -> str | None: + found = self.attributes.get(name) + return found[0] if found else None + + def line_of(self, name: str) -> int: + found = self.attributes.get(name) + return found[1] if found else self.line + + def children(self, name: str) -> list[HclBlock]: + return [b for b in self.blocks if b.kind == name] + + +def _labels(text: str) -> tuple[str, ...]: + return tuple(re.findall(r'"([^"]*)"', text)) or tuple(text.split()) + + +def _strip_comment(line: str) -> str: + quote = False + for index, char in enumerate(line): + if char == '"': + quote = not quote + elif not quote and (char == "#" or line.startswith("//", index)): + return line[:index] + return line + + +def parse_hcl(text: str) -> list[HclBlock]: + """Top-level resource, data, module and provider blocks.""" + lines = text.splitlines() + blocks: list[HclBlock] = [] + index = 0 + while index < len(lines): + header = _HEADER.match(_strip_comment(lines[index])) + if not header: + index += 1 + continue + block = HclBlock(header.group(1), _labels(header.group(2)), index + 1) + rest = _strip_comment(lines[index])[header.end() :].strip() + index = _parse_body(lines, index + 1, block, rest) + blocks.append(block) + return blocks + + +def _parse_body(lines: list[str], index: int, block: HclBlock, inline: str) -> int: + """Fill ``block`` from the lines after its opening brace and return the index after it.""" + if inline.endswith("}"): # a one-line block: resource "x" "y" { a = 1 } + for part in inline[:-1].split(";"): + attribute = _ATTRIBUTE.match(part) + if attribute: + block.attributes[attribute.group(1)] = (attribute.group(2).strip(), block.line) + return index + while index < len(lines): + raw = _strip_comment(lines[index]) + stripped = raw.strip() + number = index + 1 + index += 1 + if not stripped: + continue + if stripped.startswith("}"): + return index + nested = _BLOCK.match(raw) + if nested: + child = HclBlock(nested.group(1), _labels(nested.group(2)), number) + index = _parse_body(lines, index, child, "") + block.blocks.append(child) + continue + attribute = _ATTRIBUTE.match(raw) + if not attribute: + continue + value = attribute.group(2).strip() + heredoc = _HEREDOC.search(value) + if heredoc: + tag, body = heredoc.group(1), [value] + while index < len(lines): + body.append(lines[index]) + done = lines[index].strip() == tag + index += 1 + if done: + break + value = "\n".join(body) + else: + value, index = _balance(value, lines, index) + block.attributes[attribute.group(1)] = (value, number) + return index + + +def _balance(value: str, lines: list[str], index: int) -> tuple[str, int]: + """Join continuation lines of a multi-line list, map or call expression.""" + + def depth(text: str) -> int: + quoted = re.sub(r'"(?:[^"\\]|\\.)*"', '""', text) + return sum(quoted.count(c) for c in "([{") - sum(quoted.count(c) for c in ")]}") + + total = depth(value) + parts = [value] + while total > 0 and index < len(lines): + part = _strip_comment(lines[index]).strip() + parts.append(part) + total += depth(part) + index += 1 + return " ".join(parts), index diff --git a/devshield/core/parsers/pyast.py b/devshield/core/parsers/pyast.py new file mode 100644 index 0000000..981c7f8 --- /dev/null +++ b/devshield/core/parsers/pyast.py @@ -0,0 +1,76 @@ +"""Helpers for reading Python settings and config modules with the ast module.""" + +from __future__ import annotations + +import ast +from typing import Any + +MISSING: Any = object() + + +def parse_module(text: str) -> ast.Module | None: + try: + return ast.parse(text) + except (SyntaxError, ValueError, RecursionError): + return None + + +def literal(node: ast.expr | None) -> Any: + """The Python value of a literal expression, or MISSING when it is computed.""" + if node is None: + return MISSING + try: + return ast.literal_eval(node) + except (ValueError, TypeError, SyntaxError, MemoryError, RecursionError): + return MISSING + + +def module_assignments(tree: ast.Module) -> dict[str, tuple[ast.expr, int]]: + """Top-level ``NAME = value`` assignments. The last assignment to a name wins.""" + found: dict[str, tuple[ast.expr, int]] = {} + for node in tree.body: + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name): + found[target.id] = (node.value, node.lineno) + elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) and node.value: + found[node.target.id] = (node.value, node.lineno) + return found + + +def secret_assignments(tree: ast.Module, names: frozenset[str]) -> list[tuple[str, ast.expr, int]]: + """Every assignment, anywhere in the module, to NAME, obj.name or obj["NAME"].""" + wanted = {n.lower() for n in names} + found: list[tuple[str, ast.expr, int]] = [] + for node in ast.walk(tree): + targets: list[ast.expr] = [] + value: ast.expr | None = None + if not isinstance(node, ast.Assign | ast.AnnAssign): + continue + if isinstance(node, ast.Assign): + targets, value = list(node.targets), node.value + elif isinstance(node, ast.AnnAssign): + targets, value = [node.target], node.value + if value is None: + continue + for target in targets: + name = None + if isinstance(target, ast.Name): + name = target.id + elif isinstance(target, ast.Attribute): + name = target.attr + elif isinstance(target, ast.Subscript): + key = literal(target.slice) + name = key if isinstance(key, str) else None + if name and name.lower() in wanted: + found.append((name, value, node.lineno)) + return found + + +def string_literals(node: ast.expr) -> list[str]: + """String constants a value can evaluate to: the string itself or an ``x or "default"`` tail.""" + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return [node.value] + if isinstance(node, ast.BoolOp) and isinstance(node.op, ast.Or): + return [s for value in node.values for s in string_literals(value)] + return [] diff --git a/devshield/core/parsers/pythondeps.py b/devshield/core/parsers/pythondeps.py new file mode 100644 index 0000000..03ab440 --- /dev/null +++ b/devshield/core/parsers/pythondeps.py @@ -0,0 +1,74 @@ +"""Read Python dependency names from requirements files, pyproject.toml and Pipfile.""" + +from __future__ import annotations + +import re +import tomllib +from typing import Any + +_REQUIREMENT = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:\[[^\]]*\])?\s*(.*)$") + + +def normalize(name: str) -> str: + return re.sub(r"[-_.]+", "-", name).lower() + + +def parse_requirement(line: str) -> tuple[str, str] | None: + line = line.split(" #", 1)[0].split(";", 1)[0].strip() + if not line or line.startswith(("-", "#", ".", "/", "git+", "http")): + return None + match = _REQUIREMENT.match(line) + return (normalize(match.group(1)), match.group(2).strip()) if match else None + + +def parse_requirements_txt(text: str) -> dict[str, str]: + found: dict[str, str] = {} + for line in text.splitlines(): + parsed = parse_requirement(line) + if parsed: + found[parsed[0]] = parsed[1] + return found + + +def _from_list(items: Any, out: dict[str, str]) -> None: + for item in items if isinstance(items, list) else []: + parsed = parse_requirement(item) if isinstance(item, str) else None + if parsed: + out[parsed[0]] = parsed[1] + + +def _from_table(table: Any, out: dict[str, str]) -> None: + for name, spec in (table if isinstance(table, dict) else {}).items(): + if normalize(name) != "python": + out[normalize(name)] = spec if isinstance(spec, str) else "" + + +def parse_pyproject(text: str) -> dict[str, str]: + try: + data = tomllib.loads(text) + except tomllib.TOMLDecodeError: + return {} + found: dict[str, str] = {} + project = data.get("project", {}) + _from_list(project.get("dependencies"), found) + for group in (project.get("optional-dependencies") or {}).values(): + _from_list(group, found) + for group in (data.get("dependency-groups") or {}).values(): + _from_list(group, found) + poetry = data.get("tool", {}).get("poetry", {}) + _from_table(poetry.get("dependencies"), found) + _from_table(poetry.get("dev-dependencies"), found) + for group in (poetry.get("group") or {}).values(): + _from_table(group.get("dependencies") if isinstance(group, dict) else None, found) + return found + + +def parse_pipfile(text: str) -> dict[str, str]: + try: + data = tomllib.loads(text) + except tomllib.TOMLDecodeError: + return {} + found: dict[str, str] = {} + _from_table(data.get("packages"), found) + _from_table(data.get("dev-packages"), found) + return found diff --git a/devshield/core/search.py b/devshield/core/search.py index e7c417d..8d64998 100644 --- a/devshield/core/search.py +++ b/devshield/core/search.py @@ -84,6 +84,19 @@ def search( yield Hit(file, number, line, match) +def search_text( + ctx: ProjectContext, files: Iterable[str], pattern: re.Pattern[str] +) -> Iterator[Hit]: + """Like ``search`` but matches across lines, for code that is split over several lines.""" + for file in files: + text = ctx.read_text(file) or "" + if not may_match(text, pattern): + continue + for match in pattern.finditer(text): + line = text.count("\n", 0, match.start()) + 1 + yield Hit(file, line, text[match.start() : match.end()].split("\n", 1)[0], match) + + def is_string_literal(argument: str) -> bool: return STRING_LITERAL.fullmatch(argument.strip()) is not None diff --git a/devshield/fixers/ci.py b/devshield/fixers/ci.py index b6ff6c9..fd07cb3 100644 --- a/devshield/fixers/ci.py +++ b/devshield/fixers/ci.py @@ -64,6 +64,33 @@ path: ~/.npm key: npm-${{ hashFiles('package.json') }} """ +SETUP_PYTHON = "actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0" +SETUP_JAVA = "actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4.9.1" +SETUP_DOTNET = "actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1" +PYTHON_STEPS = """\ + - uses: {setup} + with: + python-version: "3.12" + cache: pip + - run: {install} + - run: pytest +""" +JAVA_STEPS = """\ + - uses: {setup} + with: + distribution: temurin + java-version: "21" + cache: {tool} + - run: {build} +""" +DOTNET_STEPS = """\ + - uses: {setup} + with: + dotnet-version: 8.0.x + - run: dotnet restore + - run: dotnet build --no-restore + - run: dotnet test --no-build +""" GENERIC_STEPS = """\ - run: echo "Add your build and test commands here" """ @@ -103,6 +130,22 @@ def plan(self, fc: FixContext, findings: list[Finding]) -> Iterable[Fix | Skippe ctx = fc.ctx if ctx.has_framework(Framework.LARAVEL): steps = LARAVEL_STEPS.format(setup=SETUP_PHP) + elif ctx.has_framework_any({Framework.DJANGO, Framework.FASTAPI, Framework.FLASK}): + install = ( + "pip install -r requirements.txt pytest" + if ctx.exists("requirements.txt") + else "pip install . pytest" + ) + steps = PYTHON_STEPS.format(setup=SETUP_PYTHON, install=install) + elif ctx.has_framework(Framework.SPRING): + gradle = ctx.exists("build.gradle") or ctx.exists("build.gradle.kts") + steps = JAVA_STEPS.format( + setup=SETUP_JAVA, + tool="gradle" if gradle else "maven", + build="./gradlew build" if gradle else "mvn -B verify", + ) + elif ctx.has_framework(Framework.ASPNET): + steps = DOTNET_STEPS.format(setup=SETUP_DOTNET) elif ctx.exists("package.json"): locked = ctx.exists("package-lock.json") steps = NODE_STEPS.format( diff --git a/devshield/fixers/env.py b/devshield/fixers/env.py index 18e2ff2..c43de21 100644 --- a/devshield/fixers/env.py +++ b/devshield/fixers/env.py @@ -9,7 +9,7 @@ from devshield.fixers.model import Create, Edit, Fix, Skipped DEBUG_LINE = re.compile( - r"^(?P
\s*(?:export\s+)?APP_DEBUG\s*=\s*)(?P[\"']?)" + r"^(?P\s*(?:export\s+)?(?:APP_DEBUG|FLASK_DEBUG|DJANGO_DEBUG|DEBUG)\s*=\s*)(?P[\"']?)" r"(?Ptrue|1|yes|on)(?P=quote)(?P \s*(?:#.*)?)$", re.IGNORECASE, ) @@ -22,20 +22,20 @@ class DisableDebug(Fixer): def plan(self, fc: FixContext, findings: list[Finding]) -> Iterable[Fix | Skipped]: for finding in findings: location = finding.location - if location is None or location.line is None or "APP_DEBUG" not in finding.message: - yield self.skip("only APP_DEBUG in .env files is fixed automatically", finding) + if location is None or location.line is None or " is enabled" not in finding.message: + yield self.skip("only debug flags in .env files is fixed automatically", finding) continue text = fc.workspace.read(location.file) or "" bounds = line_bounds(text, location.line) match = bounds and DEBUG_LINE.match(text[bounds[0] : bounds[1]]) if not bounds or not match: - yield self.skip("the APP_DEBUG line has an unexpected format", finding) + yield self.skip("the debug line has an unexpected format", finding) continue quote = match["quote"] replacement = f"{match['head']}{quote}false{quote}{match['tail']}" yield Fix( self.rule_id, - f"Set APP_DEBUG=false in {location.file}", + f"Disable debug mode in {location.file}", (Edit(location.file, bounds[0], bounds[1], replacement),), ) diff --git a/devshield/reports/rule_docs.py b/devshield/reports/rule_docs.py index ff17974..c7a4f2f 100644 --- a/devshield/reports/rule_docs.py +++ b/devshield/reports/rule_docs.py @@ -59,3 +59,74 @@ def render_all(rules: Iterable[type[Rule]]) -> dict[str, str]: pages = {f"{rule.id}.md": render_rule_page(rule) for rule in rules} pages["README.md"] = render_rule_index(rules) return pages + + +FRAMEWORK_ORDER = ( + ("laravel", "Laravel"), + ("node", "Node.js"), + ("express", "Express"), + ("nestjs", "NestJS"), + ("react", "React"), + ("nextjs", "Next.js"), + ("django", "Django"), + ("fastapi", "FastAPI"), + ("flask", "Flask"), + ("spring", "Spring Boot"), + ("aspnet", "ASP.NET Core"), + ("terraform", "Terraform"), + ("cloudformation", "CloudFormation"), + ("docker", "Docker"), + ("github_actions", "GitHub Actions"), + ("all", "Every project"), +) +FRAMEWORKS_INTRO = """\ +# Supported frameworks + +DevShield detects the stacks in a project and runs the rules that apply to them. This page is the +map from stack to rules; each ID links to its own page. + +## How a stack is detected + +| Stack | Signals | +|---|---| +| Laravel | `artisan` file, `laravel/framework` in composer.json | +| Node.js, Express, React, Next.js | `package.json` and the matching dependency | +| NestJS | `@nestjs/core` in package.json | +| Django | `django` in requirements, pyproject or Pipfile, or a Django `manage.py` | +| FastAPI, Flask | the package in the dependencies, or an `import` in a Python file | +| Spring Boot | `spring-boot` in `pom.xml`, or the Spring Boot plugin or starters in Gradle | +| ASP.NET Core | a `.csproj` using `Microsoft.NET.Sdk.Web` or `Microsoft.AspNetCore` packages | +| Terraform | `.tf` files | +| CloudFormation | YAML or JSON templates that declare `AWS::` resources | +| Docker, GitHub Actions | `Dockerfile` or compose files, workflow files | + +Detected stacks are listed at the top of every report. Build output (`target/`, `obj/`, +`.terraform/`) and virtual environments are never scanned. + +## Rules by stack + +A rule that applies to several stacks is listed under each of them. +""" +FRAMEWORKS_OUTRO = """\ +## Adding a stack + +Each new stack needs a detector in `devshield/core/detect.py`, a rule pack under +`devshield/rules/`, fixtures in `tests/fixtures/` (one insecure, one clean) and documentation. +Third parties can ship rules for other stacks as [plugins](plugins.md). +""" + + +def render_frameworks_page(rules: Iterable[type[Rule]]) -> str: + grouped: dict[str, list[type[Rule]]] = {} + for rule in rules: + for framework in sorted(f.value for f in rule.applies_to) or ["all"]: + grouped.setdefault(framework, []).append(rule) + sections = [] + for key, label in FRAMEWORK_ORDER: + if key not in grouped: + continue + rows = "\n".join( + f"| [{r.id}](rules/{r.id}.md) | {r.severity.value} | {r.title} |" for r in grouped[key] + ) + sections.append(f"### {label}\n\n| Rule | Severity | Title |\n|---|---|---|\n{rows}\n") + return FRAMEWORKS_INTRO + "\n" + "\n".join(sections) + "\n" + FRAMEWORKS_OUTRO diff --git a/devshield/reports/terminal.py b/devshield/reports/terminal.py index 0b5ef63..5d66a1f 100644 --- a/devshield/reports/terminal.py +++ b/devshield/reports/terminal.py @@ -17,6 +17,14 @@ Framework.NODE: "Node.js detected", Framework.DOCKER: "Docker detected", Framework.GITHUB_ACTIONS: "GitHub Actions detected", + Framework.DJANGO: "Framework detected: Django", + Framework.FASTAPI: "Framework detected: FastAPI", + Framework.FLASK: "Framework detected: Flask", + Framework.NESTJS: "Framework detected: NestJS", + Framework.SPRING: "Framework detected: Spring Boot", + Framework.ASPNET: "Framework detected: ASP.NET Core", + Framework.TERRAFORM: "Terraform detected", + Framework.CLOUDFORMATION: "CloudFormation detected", } SEVERITY_STYLE = { Severity.CRITICAL: ("✖", "bold red"), diff --git a/devshield/rules/_bases.py b/devshield/rules/_bases.py index fd2713b..f445a5b 100644 --- a/devshield/rules/_bases.py +++ b/devshield/rules/_bases.py @@ -11,7 +11,10 @@ if TYPE_CHECKING: from devshield.core.context import ProjectContext -APPLICATION_FRAMEWORKS = frozenset(Framework) - {Framework.DOCKER, Framework.GITHUB_ACTIONS} +INFRASTRUCTURE_FRAMEWORKS = frozenset( + {Framework.DOCKER, Framework.GITHUB_ACTIONS, Framework.TERRAFORM, Framework.CLOUDFORMATION} +) +APPLICATION_FRAMEWORKS = frozenset(Framework) - INFRASTRUCTURE_FRAMEWORKS DEPLOYED_NAMES = ("production", "prod", "staging", "stage") @@ -28,6 +31,14 @@ def is_deployed_env(ctx: ProjectContext, file: str, values: dict[str, str]) -> b ) +DEV_FILE = re.compile(r"(?:^|[-_./])(?:dev|development|local|test|testing|debug)(?:[-_./]|$)", re.I) + + +def is_dev_file(rel: str) -> bool: + """Files meant for local development, where debug-style settings are expected.""" + return DEV_FILE.search(rel.rsplit("/", 1)[-1]) is not None + + def is_application(ctx: ProjectContext) -> bool: return any(d.framework in APPLICATION_FRAMEWORKS for d in ctx.frameworks) diff --git a/devshield/rules/_frameworks.py b/devshield/rules/_frameworks.py new file mode 100644 index 0000000..820bb33 --- /dev/null +++ b/devshield/rules/_frameworks.py @@ -0,0 +1,46 @@ +"""Helpers shared by the framework rule packs. Modules starting with _ are not loaded as rules.""" + +from __future__ import annotations + +import re + +from devshield.core.context import ProjectContext +from devshield.core.parsers import module_assignments +from devshield.rules._bases import is_dev_file + + +def django_settings(ctx: ProjectContext, include_dev: bool = False) -> list[str]: + """Settings modules: ``settings.py`` or any module inside a ``settings/`` package.""" + files = [ + f + for f in ctx.python_files + if f.rsplit("/", 1)[-1] == "settings.py" or "/settings/" in f"/{f}" + ] + return files if include_dev else [f for f in files if not is_dev_file(f)] + + +def settings_values( + ctx: ProjectContext, include_dev: bool = False +) -> dict[str, tuple[object, int, str]]: + """Top-level settings across every settings module: name -> (expression, line, file).""" + merged: dict[str, tuple[object, int, str]] = {} + for file in django_settings(ctx, include_dev): + tree = ctx.pyast(file) + for name, (node, line) in (module_assignments(tree) if tree else {}).items(): + merged[name] = (node, line, file) + return merged + + +SPRING_CONFIG = ("application*.properties", "application*.yml", "application*.yaml", + "bootstrap*.properties", "bootstrap*.yml", "bootstrap*.yaml") # fmt: skip +SPRING_COOKIE_KEYS = frozenset( + {"server.servlet.session.cookie.secure", "server.servlet.session.cookie.http-only"} +) +DOTNET_COOKIE = re.compile( + r"CookieSecurePolicy\.None|\bHttpOnly\s*=\s*(?:false|HttpOnlyPolicy\.None)\b" + r"|\bSecure\s*=\s*CookieSecurePolicy\.None" +) + + +def spring_config_files(ctx: ProjectContext) -> list[str]: + return ctx.match(*SPRING_CONFIG) diff --git a/devshield/rules/appconfig.py b/devshield/rules/appconfig.py new file mode 100644 index 0000000..2bd852c --- /dev/null +++ b/devshield/rules/appconfig.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import re +from collections.abc import Iterator + +from devshield.core.context import ProjectContext +from devshield.core.models import Category, Finding, Framework, Severity +from devshield.core.rule import Rule +from devshield.core.secrets import PLACEHOLDER +from devshield.rules._bases import is_dev_file +from devshield.rules._frameworks import spring_config_files + +SECRET_KEY_NAME = re.compile( + r"(?:password|passwd|pwd|secret|api[-_]?key|access[-_]?key|private[-_]?key|client[-_]?secret" + r"|token|credential|signing[-_]?key|jwt[-_.:]?key)", + re.I, +) +NOT_A_SECRET = re.compile( + r"(?:^|[-_.:])(?:ttl|expires?|expiration|lifetime|days|enabled|name|path|file|url|uri|id|" + r"header|length|prefix|type|policy|provider|uri|endpoint|min|max|validity|timeout)(?:$|[-_.:\]])", + re.I, +) +PLAIN_VALUE = re.compile(r"^(?:\d+|true|false|null|none)$", re.I) +CONNECTION_PASSWORD = re.compile(r"(?:password|pwd)\s*=\s*([^;]+)", re.I) +URL_PASSWORD = re.compile(r"://[^\s:@/]+:([^\s@/]{3,})@") +APPSETTINGS = ("appsettings*.json", "appsettings*.yml", "appsettings*.yaml") + + +def _is_placeholder(value: str) -> bool: + return ( + not value.strip() + or PLAIN_VALUE.match(value.strip()) is not None + or PLACEHOLDER.search(value) is not None + or value.startswith(("$", "#", "%", "{", "<", "@")) + ) + + +class CredentialInConfigFile(Rule): + id = "SH207" + title = "Credential written in an application config file" + severity = Severity.HIGH + category = Category.SECRETS + applies_to = frozenset({Framework.SPRING, Framework.ASPNET}) + description = ( + "Passwords, API keys and signing keys in application.properties, application.yml or " + "appsettings.json are committed with the code and readable by everyone with access " + "to the repository." + ) + remediation = ( + "Reference an environment variable (${DB_PASSWORD} in Spring, environment variables or " + "user-secrets in .NET) or a secret manager, and rotate the exposed value." + ) + + def check(self, ctx: ProjectContext) -> Iterator[Finding]: + files = [] + if ctx.has_framework(Framework.SPRING): + files += spring_config_files(ctx) + if ctx.has_framework(Framework.ASPNET): + files += ctx.match(*APPSETTINGS) + for file in files: + development = is_dev_file(file) or file.lower().endswith( + ("appsettings.development.json", "appsettings.local.json") + ) + for entry in ctx.config_entries(file): + problem = self._problem(entry.key, entry.value) + if problem: + yield self.finding( + f"{entry.key} {problem}", + file=file, + line=entry.line, + severity=Severity.MEDIUM if development else None, + ) + + @staticmethod + def _problem(key: str, value: str) -> str | None: + last = re.split(r"[.:]", key)[-1] + if "connectionstring" in key.lower() or ";" in value: + match = CONNECTION_PASSWORD.search(value) + if match and not _is_placeholder(match.group(1).strip()): + return "contains a password" + if URL_PASSWORD.search(value) and not _is_placeholder(URL_PASSWORD.search(value).group(1)): # type: ignore[union-attr] + return "contains a password in a URL" + if ( + SECRET_KEY_NAME.search(key) + and not NOT_A_SECRET.search(last) + and not _is_placeholder(value) + ): + return "holds a literal secret" + return None diff --git a/devshield/rules/cloud.py b/devshield/rules/cloud.py new file mode 100644 index 0000000..650321c --- /dev/null +++ b/devshield/rules/cloud.py @@ -0,0 +1,434 @@ +"""Rules for AWS infrastructure as code: Terraform (.tf) and CloudFormation templates.""" + +from __future__ import annotations + +import re +from collections.abc import Iterator +from dataclasses import dataclass +from typing import Any, ClassVar + +from devshield.core.context import ProjectContext +from devshield.core.models import Category, Finding, Framework, Severity +from devshield.core.parsers import HclBlock +from devshield.core.rule import Rule + +IAC = frozenset({Framework.TERRAFORM, Framework.CLOUDFORMATION}) +PUBLIC_ACLS = frozenset({"public-read", "public-read-write", "authenticated-read"}) +PUBLIC_CFN_ACLS = frozenset({"PublicRead", "PublicReadWrite", "AuthenticatedRead"}) +BLOCK_SETTINGS = ( + "block_public_acls", + "block_public_policy", + "ignore_public_acls", + "restrict_public_buckets", +) +CFN_BLOCK_SETTINGS = ( + "BlockPublicAcls", + "BlockPublicPolicy", + "IgnorePublicAcls", + "RestrictPublicBuckets", +) +ADMIN_PORTS = (22, 3389) +DATABASE_PORTS = (1433, 3306, 5432, 6379, 9200, 11211, 27017) +OPEN_CIDRS = ("0.0.0.0/0", "::/0") +WILDCARD = r"""(?:"\*"|\[[^\]]*"\*"[^\]]*\])""" +POLICY_ACTION = re.compile(rf"""["']?Action["']?\s*[:=]\s*{WILDCARD}""") +POLICY_RESOURCE = re.compile(rf"""["']?Resource["']?\s*[:=]\s*{WILDCARD}""") +PUBLIC_PRINCIPAL = re.compile( + r"""["']?Principal["']?\s*[:=]\s*(?:["']\*["']|\{\s*["']?AWS["']?\s*[:=]\s*["']\*["']\s*\})""" +) +IAM_POLICY_TYPES = frozenset( + {"aws_iam_policy", "aws_iam_role_policy", "aws_iam_user_policy", "aws_iam_group_policy"} +) + + +@dataclass(frozen=True) +class Spot: + file: str + line: int + + +def unquote(value: str | None) -> str: + return (value or "").strip().strip('"').strip("'") + + +def as_bool(value: Any) -> bool | None: + text = str(value).strip().strip('"').lower() if value is not None else "" + return True if text == "true" else False if text == "false" else None + + +def terraform_blocks(ctx: ProjectContext) -> Iterator[tuple[str, HclBlock]]: + for file in ctx.terraform_files: + for block in ctx.terraform(file): + if block.kind in {"resource", "data"}: + yield file, block + + +def cfn_resources(ctx: ProjectContext) -> Iterator[tuple[str, str, str, dict[str, Any]]]: + """(file, logical id, resource type, properties) for each CloudFormation resource.""" + for file in ctx.cloudformation_files: + template = ctx.template(file) + resources = template.get("Resources") if template else None + for logical_id, body in (resources if isinstance(resources, dict) else {}).items(): + if isinstance(body, dict) and isinstance(body.get("Type"), str): + props = body.get("Properties") + yield file, str(logical_id), body["Type"], props if isinstance(props, dict) else {} + + +def cfn_line(ctx: ProjectContext, file: str, logical_id: str, *path: str) -> int: + for depth in range(len(path), -1, -1): + line = ctx.line_of( + file, "Resources", logical_id, *(["Properties", *path[:depth]] if depth else []) + ) + if line: + return line + return 1 + + +def _as_list(value: Any) -> list[Any]: + return value if isinstance(value, list) else [value] + + +def cfn_statements(document: Any) -> Iterator[dict[str, Any]]: + statements = document.get("Statement") if isinstance(document, dict) else None + for statement in _as_list(statements) if statements else []: + if isinstance(statement, dict): + yield statement + + +def _has_star(value: Any) -> bool: + return any(item == "*" for item in _as_list(value)) + + +class PublicBucket(Rule): + id = "SH401" + title = "S3 bucket is publicly accessible" + severity = Severity.HIGH + category = Category.SECURITY + applies_to = IAC + description = ( + "A public S3 bucket lets anyone on the internet read, and sometimes write, its objects. " + "Public buckets are a leading cause of data leaks." + ) + remediation = ( + "Use a private ACL, enable all four settings of the public access block, and grant " + "access with specific principals or CloudFront origin access control." + ) + + def check(self, ctx: ProjectContext) -> Iterator[Finding]: + for file, block in terraform_blocks(ctx): + if block.kind != "resource": + continue + kind = block.type + if ( + kind in {"aws_s3_bucket", "aws_s3_bucket_acl"} + and unquote(block.value("acl")) in PUBLIC_ACLS + ): + yield self.finding( + f"{block.labels[-1]} uses the {unquote(block.value('acl'))} ACL", + file=file, + line=block.line_of("acl"), + ) + elif kind == "aws_s3_bucket_public_access_block": + for setting in BLOCK_SETTINGS: + if as_bool(block.value(setting)) is False: + yield self.finding( + f"Public access block sets {setting} = false", + file=file, + line=block.line_of(setting), + ) + elif kind == "aws_s3_bucket_policy" and self._public_policy( + block.value("policy") or "" + ): + yield self.finding( + "Bucket policy allows every principal", + file=file, + line=block.line_of("policy"), + ) + for file, logical_id, kind, props in cfn_resources(ctx): + if kind == "AWS::S3::Bucket": + acl = props.get("AccessControl") + if acl in PUBLIC_CFN_ACLS: + yield self.finding( + f"{logical_id} uses the {acl} access control", + file=file, + line=cfn_line(ctx, file, logical_id, "AccessControl"), + ) + access_block = props.get("PublicAccessBlockConfiguration") + for setting in CFN_BLOCK_SETTINGS: + if ( + isinstance(access_block, dict) + and as_bool(access_block.get(setting)) is False + ): + yield self.finding( + f"Public access block sets {setting} to false", + file=file, + line=cfn_line( + ctx, file, logical_id, "PublicAccessBlockConfiguration", setting + ), + ) + elif kind == "AWS::S3::BucketPolicy": + for statement in cfn_statements(props.get("PolicyDocument")): + principal = statement.get("Principal") + everyone = principal == "*" or ( + isinstance(principal, dict) and _has_star(principal.get("AWS")) + ) + if ( + statement.get("Effect") == "Allow" + and everyone + and "Condition" not in statement + ): + yield self.finding( + f"{logical_id} policy allows every principal", + file=file, + line=cfn_line(ctx, file, logical_id, "PolicyDocument"), + ) + + @staticmethod + def _public_policy(text: str) -> bool: + return ( + PUBLIC_PRINCIPAL.search(text) is not None + and "Allow" in text + and "Condition" not in text + and "Deny" not in text + ) + + +class WildcardIamPolicy(Rule): + id = "SH402" + title = "IAM policy grants wildcard permissions" + severity = Severity.HIGH + category = Category.SECURITY + applies_to = IAC + description = ( + "An Allow statement with Action '*' grants every API action, and with Resource '*' as " + "well it is full administrator access. A leaked credential then controls the account." + ) + remediation = "List the specific actions and resources the workload needs (least privilege)." + + def check(self, ctx: ProjectContext) -> Iterator[Finding]: + for file, block in terraform_blocks(ctx): + if block.kind == "resource" and block.type in IAM_POLICY_TYPES: + text = block.value("policy") or "" + if "Deny" in text: + continue + if POLICY_ACTION.search(text): + yield self._wildcard( + file, block.line_of("policy"), bool(POLICY_RESOURCE.search(text)) + ) + elif block.kind == "data" and block.type == "aws_iam_policy_document": + for item in block.children("statement"): + effect = unquote(item.value("effect")) or "Allow" + actions = item.value("actions") or "" + resources = item.value("resources") or "" + if effect == "Allow" and re.search(r'"\*"', actions): + yield self._wildcard(file, item.line, bool(re.search(r'"\*"', resources))) + for file, logical_id, kind, props in cfn_resources(ctx): + documents: list[Any] = [] + if kind in {"AWS::IAM::Policy", "AWS::IAM::ManagedPolicy"}: + documents.append(props.get("PolicyDocument")) + elif kind in {"AWS::IAM::Role", "AWS::IAM::User", "AWS::IAM::Group"}: + for policy in _as_list(props.get("Policies") or []): + documents.append( + policy.get("PolicyDocument") if isinstance(policy, dict) else None + ) + for document in documents: + for statement in cfn_statements(document): + if statement.get("Effect") == "Allow" and _has_star(statement.get("Action")): + yield self._wildcard( + file, + cfn_line(ctx, file, logical_id), + _has_star(statement.get("Resource")), + ) + + def _wildcard(self, file: str, line: int, resource_star: bool) -> Finding: + return self.finding( + "Allows every action on every resource" if resource_star else "Allows every action", + file=file, + line=line, + severity=Severity.CRITICAL if resource_star else None, + ) + + +def _port(value: Any) -> int | None: + text = str(value).strip().strip('"') if value is not None else "" + return int(text) if text.lstrip("-").isdigit() else None + + +def _exposure(low: int | None, high: int | None, protocol: str) -> tuple[Severity, str] | None: + if protocol in {"-1", "all"}: + return Severity.HIGH, "all ports" + if low is None or high is None: + return None + if low <= 0 and high >= 65535: + return Severity.HIGH, "all ports" + for port in ADMIN_PORTS: + if low <= port <= high: + return Severity.HIGH, f"port {port}" + for port in DATABASE_PORTS: + if low <= port <= high: + return Severity.MEDIUM, f"database port {port}" + return None + + +class OpenSecurityGroup(Rule): + id = "SH403" + title = "Security group open to the internet" + severity = Severity.HIGH + category = Category.SECURITY + applies_to = IAC + description = ( + "Ingress from 0.0.0.0/0 to SSH, RDP, databases or every port exposes those services " + "to constant scanning and brute-force attempts." + ) + remediation = ( + "Restrict the source to your own CIDR range or a security group, and use SSM Session " + "Manager or a VPN instead of public SSH." + ) + + def check(self, ctx: ProjectContext) -> Iterator[Finding]: + for file, block in terraform_blocks(ctx): + if block.kind != "resource": + continue + if block.type == "aws_security_group": + for ingress in block.children("ingress"): + yield from self._tf( + file, ingress, "cidr_blocks", "ipv6_cidr_blocks", "protocol" + ) + elif ( + block.type == "aws_security_group_rule" + and unquote(block.value("type")) == "ingress" + ): + yield from self._tf(file, block, "cidr_blocks", "ipv6_cidr_blocks", "protocol") + elif block.type == "aws_vpc_security_group_ingress_rule": + yield from self._tf(file, block, "cidr_ipv4", "cidr_ipv6", "ip_protocol") + for file, logical_id, kind, props in cfn_resources(ctx): + rules: list[Any] = [] + if kind == "AWS::EC2::SecurityGroup": + rules = _as_list(props.get("SecurityGroupIngress") or []) + elif kind == "AWS::EC2::SecurityGroupIngress": + rules = [props] + for rule in rules: + if not isinstance(rule, dict): + continue + if not any(rule.get(k) in OPEN_CIDRS for k in ("CidrIp", "CidrIpv6")): + continue + found = _exposure( + _port(rule.get("FromPort")), + _port(rule.get("ToPort")), + str(rule.get("IpProtocol", "")), + ) + if found: + yield self.finding( + f"{logical_id} allows the internet to reach {found[1]}", + file=file, + line=cfn_line(ctx, file, logical_id), + severity=found[0], + ) + + def _tf( + self, file: str, block: HclBlock, ipv4: str, ipv6: str, protocol: str + ) -> Iterator[Finding]: + cidrs = (block.value(ipv4) or "") + (block.value(ipv6) or "") + if not any(cidr in cidrs for cidr in OPEN_CIDRS): + return + found = _exposure( + _port(block.value("from_port")), + _port(block.value("to_port")), + unquote(block.value(protocol)), + ) + if found: + yield self.finding( + f"Ingress from the internet to {found[1]}", + file=file, + line=block.line, + severity=found[0], + ) + + +class UnencryptedStorage(Rule): + id = "SH404" + title = "Storage encryption at rest not enabled" + severity = Severity.MEDIUM + category = Category.SECURITY + applies_to = IAC + description = ( + "Unencrypted databases and volumes expose their data if a snapshot, a disk or a backup " + "is copied or shared by mistake, and many compliance regimes require encryption." + ) + remediation = ( + "Set storage_encrypted = true (RDS) or encrypted = true (EBS), ideally with a KMS key." + ) + tf_attributes: ClassVar[dict[str, str]] = { + "aws_db_instance": "storage_encrypted", + "aws_rds_cluster": "storage_encrypted", + "aws_ebs_volume": "encrypted", + } + cfn_attributes: ClassVar[dict[str, str]] = { + "AWS::RDS::DBInstance": "StorageEncrypted", + "AWS::RDS::DBCluster": "StorageEncrypted", + "AWS::EC2::Volume": "Encrypted", + } + restored_from = ("snapshot_identifier", "replicate_source_db", "source_db_instance_identifier") + cfn_restored_from = ( + "DBSnapshotIdentifier", + "SourceDBInstanceIdentifier", + "SnapshotIdentifier", + "SourceDBClusterIdentifier", + ) + + def check(self, ctx: ProjectContext) -> Iterator[Finding]: + for file, block in terraform_blocks(ctx): + attribute = self.tf_attributes.get(block.type) if block.kind == "resource" else None + if attribute is None or any(block.value(a) for a in self.restored_from): + continue + if as_bool(block.value(attribute)) is not True and ( + block.value(attribute) is None or as_bool(block.value(attribute)) is False + ): + yield self.finding( + f"{block.labels[-1]} does not set {attribute} = true", + file=file, + line=block.line_of(attribute), + ) + for file, logical_id, kind, props in cfn_resources(ctx): + attribute = self.cfn_attributes.get(kind) + if attribute is None or any(props.get(a) for a in self.cfn_restored_from): + continue + value = props.get(attribute) + if value is None or as_bool(value) is False: + yield self.finding( + f"{logical_id} does not set {attribute} to true", + file=file, + line=cfn_line(ctx, file, logical_id, attribute), + ) + + +class PubliclyAccessibleDatabase(Rule): + id = "SH405" + title = "Database reachable from the internet" + severity = Severity.HIGH + category = Category.SECURITY + applies_to = IAC + description = ( + "A database with a public address is exposed to the internet and to brute-force and " + "exploit attempts, even when a security group limits who can connect." + ) + remediation = ( + "Set publicly_accessible = false and reach the database through a private subnet, " + "a VPN or a bastion host." + ) + + def check(self, ctx: ProjectContext) -> Iterator[Finding]: + for file, block in terraform_blocks(ctx): + if block.kind == "resource" and as_bool(block.value("publicly_accessible")) is True: + yield self.finding( + f"{block.labels[-1]} has publicly_accessible = true", + file=file, + line=block.line_of("publicly_accessible"), + ) + for file, logical_id, _kind, props in cfn_resources(ctx): + if as_bool(props.get("PubliclyAccessible")) is True: + yield self.finding( + f"{logical_id} has PubliclyAccessible: true", + file=file, + line=cfn_line(ctx, file, logical_id, "PubliclyAccessible"), + ) diff --git a/devshield/rules/environment.py b/devshield/rules/environment.py index 00e50f3..19313b0 100644 --- a/devshield/rules/environment.py +++ b/devshield/rules/environment.py @@ -4,14 +4,27 @@ from collections.abc import Iterator from devshield.core.context import ProjectContext -from devshield.core.models import Category, Finding, Severity -from devshield.core.parsers import final_stage, parse_php_config +from devshield.core.models import Category, Finding, Framework, Severity +from devshield.core.parsers import final_stage, literal, module_assignments, parse_php_config from devshield.core.rule import Rule +from devshield.core.search import search from devshield.rules._bases import is_deployed_env +from devshield.rules._frameworks import django_settings TRUTHY = frozenset({"true", "1", "yes", "on"}) LOCAL_ENV_FILES = (".env.local", ".env.development", ".env.test", ".env.testing") -CONTAINER_DEBUG = re.compile(r"\b(?:APP_DEBUG\s*=\s*(?:true|1)|NODE_ENV\s*=\s*development)\b", re.I) +CONTAINER_DEBUG = re.compile( + r"\b(?:(?:APP|FLASK|DJANGO)_DEBUG\s*=\s*(?:true|1)|NODE_ENV\s*=\s*development" + r"|FLASK_ENV\s*=\s*development|ASPNETCORE_ENVIRONMENT\s*=\s*Development)\b", + re.I, +) +DEBUG_KEYS = ("APP_DEBUG", "FLASK_DEBUG", "DJANGO_DEBUG") +PYTHON_FRAMEWORKS = (Framework.DJANGO, Framework.FASTAPI, Framework.FLASK) +FLASK_DEBUG = re.compile( + r"\.run\([^)]*\bdebug\s*=\s*True|\bapp\.debug\s*=\s*True" + r"|config\[['\"]DEBUG['\"]\]\s*=\s*True|\bFastAPI\([^)]*\bdebug\s*=\s*True" +) +DEVELOPER_PAGE = re.compile(r"\.UseDeveloperExceptionPage\(") MAX_LISTED_KEYS = 10 @@ -51,15 +64,18 @@ def check(self, ctx: ProjectContext) -> Iterator[Finding]: continue entries = {e.key: e for e in ctx.dotenv(file)} values = {key: entry.value for key, entry in entries.items()} - debug = entries.get("APP_DEBUG") - if debug and debug.value.lower() in TRUTHY and is_deployed_env(ctx, file, values): - production = "production" in file or values.get("APP_ENV") == "production" - yield self.finding( - "APP_DEBUG is enabled" + (" in a production environment" if production else ""), - file=file, - line=debug.line, - severity=Severity.CRITICAL if production else None, - ) + keys = DEBUG_KEYS + (("DEBUG",) if ctx.has_framework_any(PYTHON_FRAMEWORKS) else ()) + for key in keys: + debug = entries.get(key) + if debug and debug.value.lower() in TRUTHY and is_deployed_env(ctx, file, values): + production = "production" in file or values.get("APP_ENV") == "production" + yield self.finding( + f"{key} is enabled" + + (" in a production environment" if production else ""), + file=file, + line=debug.line, + severity=Severity.CRITICAL if production else None, + ) parsed = parse_php_config(ctx.read_text("config/app.php") or "") if parsed and parsed[0].get("debug") is True: yield self.finding( @@ -68,6 +84,7 @@ def check(self, ctx: ProjectContext) -> Iterator[Finding]: line=parsed[1].get(("debug",)), fixable=False, ) + yield from self._python_and_dotnet(ctx) for file in ctx.dockerfiles: for instruction in final_stage(ctx.dockerfile(file)): if instruction.name == "ENV" and CONTAINER_DEBUG.search(instruction.args): @@ -78,6 +95,31 @@ def check(self, ctx: ProjectContext) -> Iterator[Finding]: fixable=False, ) + def _python_and_dotnet(self, ctx: ProjectContext) -> Iterator[Finding]: + for file in django_settings(ctx): + tree = ctx.pyast(file) + entry = (module_assignments(tree) if tree else {}).get("DEBUG") + if entry and literal(entry[0]) is True: + yield self.finding( + "Django settings set DEBUG = True", file=file, line=entry[1], fixable=False + ) + if ctx.has_framework_any(PYTHON_FRAMEWORKS): + for hit in search(ctx, ctx.python_files, FLASK_DEBUG): + yield self.finding( + "The app is started in debug mode", file=hit.file, line=hit.line, fixable=False + ) + for file in ctx.csharp_files: + text = ctx.read_text(file) or "" + if "IsDevelopment" in text or "IsEnvironment" in text: + continue + for hit in search(ctx, [file], DEVELOPER_PAGE): + yield self.finding( + "The developer exception page is enabled in every environment", + file=file, + line=hit.line, + fixable=False, + ) + class EnvExampleMissing(Rule): id = "SH301" diff --git a/devshield/rules/java.py b/devshield/rules/java.py new file mode 100644 index 0000000..a355dd8 --- /dev/null +++ b/devshield/rules/java.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import re +from collections.abc import Iterator + +from devshield.core.context import ProjectContext +from devshield.core.models import Category, Finding, Framework, Severity +from devshield.core.rule import Rule +from devshield.core.search import search_text +from devshield.rules._bases import is_dev_file +from devshield.rules._frameworks import spring_config_files + +SPRING = frozenset({Framework.SPRING}) +RISKY_ENDPOINTS = frozenset( + { + "env", "heapdump", "threaddump", "beans", "configprops", "mappings", "shutdown", + "jolokia", "httptrace", "auditevents", "caches", "conditions", "sessions", "scheduledtasks", + } +) # fmt: skip +INCLUDE_KEY = "management.endpoints.web.exposure.include" + + +def _entries(ctx: ProjectContext) -> Iterator[tuple[str, str, str, int]]: + """(file, lowercase key, value, line) of every production Spring config entry.""" + for file in spring_config_files(ctx): + if is_dev_file(file): + continue + for entry in ctx.config_entries(file): + yield file, entry.key.lower().replace("_", "-"), entry.value.strip(), entry.line + + +class ExposedManagementEndpoints(Rule): + id = "SH018" + title = "Spring Boot management or debug endpoints exposed" + severity = Severity.HIGH + category = Category.SECURITY + applies_to = SPRING + description = ( + "Actuator endpoints such as env, heapdump and beans reveal configuration, secrets in " + "memory and the application's structure, and /shutdown can stop the service. The H2 " + "console and stack traces in error responses hand attackers the same kind of detail." + ) + remediation = ( + "Expose only health and info over HTTP, put the rest behind authentication or a " + "separate management port, and turn the H2 console and stack traces off." + ) + + def check(self, ctx: ProjectContext) -> Iterator[Finding]: + for file, key, value, line in _entries(ctx): + names = {n.strip().lower() for n in value.split(",")} + if key == INCLUDE_KEY and ("*" in names or names & RISKY_ENDPOINTS): + listed = ( + "all endpoints" if "*" in names else ", ".join(sorted(names & RISKY_ENDPOINTS)) + ) + yield self.finding(f"Actuator exposes {listed}", file=file, line=line) + elif key == "management.endpoint.shutdown.enabled" and value.lower() == "true": + yield self.finding("The /shutdown endpoint is enabled", file=file, line=line) + elif key == "management.security.enabled" and value.lower() == "false": + yield self.finding("Management endpoint security is disabled", file=file, line=line) + elif key == "spring.h2.console.enabled" and value.lower() == "true": + yield self.finding("The H2 database console is enabled", file=file, line=line) + elif key == "management.endpoint.health.show-details" and value.lower() == "always": + yield self.finding( + "Health details are shown to everyone", + file=file, + line=line, + severity=Severity.MEDIUM, + ) + elif key == "server.error.include-stacktrace" and value.lower() == "always": + yield self.finding( + "Error responses include stack traces", + file=file, + line=line, + severity=Severity.MEDIUM, + ) + + +class PermitAllRequests(Rule): + id = "SH108" + title = "Spring Security allows every request" + severity = Severity.HIGH + category = Category.AUTHENTICATION + applies_to = SPRING + description = ( + "permitAll() on every request, or on /**, switches authentication and authorization " + "off for the whole application." + ) + remediation = ( + "End the rule chain with anyRequest().authenticated() and permit only the specific " + "public paths, such as the login page and static assets." + ) + pattern = re.compile( + r"\banyRequest\(\)\s*\.\s*permitAll\(\)" + r"|\b(?:antMatchers|requestMatchers|mvcMatchers)\(\s*\"/\*\*\"\s*\)\s*\.\s*permitAll\(\)" + ) + + def check(self, ctx: ProjectContext) -> Iterator[Finding]: + for hit in search_text(ctx, ctx.java_files, self.pattern): + yield self.finding( + "Every request is permitted without authentication", file=hit.file, line=hit.line + ) + + +class SchemaManagedByHibernate(Rule): + id = "SH303" + title = "Hibernate changes the database schema at startup" + severity = Severity.MEDIUM + category = Category.CONFIGURATION + applies_to = SPRING + description = ( + "ddl-auto set to create, create-drop or update lets the application drop or alter " + "tables on startup, which can destroy production data." + ) + remediation = ( + "Set spring.jpa.hibernate.ddl-auto to validate or none and manage the schema with " + "Flyway or Liquibase." + ) + + def check(self, ctx: ProjectContext) -> Iterator[Finding]: + for file, key, value, line in _entries(ctx): + if key == "spring.jpa.hibernate.ddl-auto" and value.lower() in { + "create", + "create-drop", + "update", + }: + yield self.finding(f"ddl-auto is {value.lower()}", file=file, line=line) diff --git a/devshield/rules/nest.py b/devshield/rules/nest.py new file mode 100644 index 0000000..a4f8cc3 --- /dev/null +++ b/devshield/rules/nest.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import re +from collections.abc import Iterator + +from devshield.core.context import ProjectContext +from devshield.core.models import Category, Finding, Framework, Severity +from devshield.core.rule import Rule +from devshield.core.search import search + +NEST = frozenset({Framework.NESTJS}) +VALIDATION_PIPE = re.compile(r"\bValidationPipe\b") + + +class ValidationPipeMissing(Rule): + id = "SH109" + title = "NestJS request validation not enabled" + severity = Severity.MEDIUM + category = Category.AUTHENTICATION + applies_to = NEST + description = ( + "Without a ValidationPipe, the decorators on your DTO classes are ignored and request " + "bodies reach your handlers unchecked, which invites injection and mass-assignment bugs." + ) + remediation = ( + "Add `app.useGlobalPipes(new ValidationPipe({ whitelist: true }))` in main.ts, or " + "register it with the APP_PIPE provider." + ) + + def check(self, ctx: ProjectContext) -> Iterator[Finding]: + if any(search(ctx, ctx.source_files, VALIDATION_PIPE)): + return + entry = next((f for f in ctx.source_files if f.endswith("main.ts")), "package.json") + yield self.finding("No ValidationPipe is configured", file=entry) diff --git a/devshield/rules/node.py b/devshield/rules/node.py index 604afc8..dfa5dda 100644 --- a/devshield/rules/node.py +++ b/devshield/rules/node.py @@ -68,11 +68,15 @@ class WildcardCors(Rule): r"""origin\s*:\s*['"]\*['"]|Access-Control-Allow-Origin['"]\s*,\s*['"]\*['"]""" ) bare = re.compile(r"\bcors\(\s*\)") + nest_bare = re.compile(r"\benableCors\(\s*\)") + nest_open = re.compile(r"\borigin\s*:\s*true\b") def check(self, ctx: ProjectContext) -> Iterator[Finding]: for file in ctx.source_files: text = ctx.read_text(file) or "" patterns = [self.wildcard, *([self.bare] if CORS_IMPORT.search(text) else [])] + if "enableCors" in text: + patterns += [self.nest_bare, self.nest_open] seen: set[int] = set() for pattern in patterns: for hit in search(ctx, [file], pattern): @@ -94,12 +98,14 @@ class MissingSecurityHeaders(Rule): title = "Security headers not configured" severity = Severity.MEDIUM category = Category.SECURITY - applies_to = EXPRESS + applies_to = EXPRESS | frozenset({Framework.NESTJS}) description = ( "Headers such as Content-Security-Policy and Strict-Transport-Security block whole " "classes of attacks (XSS, clickjacking, protocol downgrade) with no application changes." ) - remediation = "Install helmet and add `app.use(helmet())` before your routes." + remediation = ( + "Install helmet and add `app.use(helmet())` before your routes (in NestJS, in main.ts)." + ) def check(self, ctx: ProjectContext) -> Iterator[Finding]: if _uses_helmet(ctx) or any(search(ctx, ctx.source_files, SECURITY_HEADERS)): diff --git a/devshield/rules/project.py b/devshield/rules/project.py index 8de1f7f..0f8c18e 100644 --- a/devshield/rules/project.py +++ b/devshield/rules/project.py @@ -23,6 +23,12 @@ "*_test.*", "test_*.py", "*Test.php", + "src/test/", + "*Test.java", + "*Tests.java", + "*Tests.cs", + "*Test.cs", + "*.Tests/", ) CI_FILES = ( ".github/workflows/*.yml", @@ -47,7 +53,15 @@ LABEL_VERSION = re.compile(r"(?:org\.opencontainers\.image\.)?version\s*=\s*\"?([^\"\s]+)") LARGE_FILE = 5 * 1024 * 1024 MIN_README_CHARS = 200 -IGNORED_DIRECTORIES = {Framework.NODE: "node_modules", Framework.LARAVEL: "vendor"} +IGNORED_DIRECTORIES = { + Framework.NODE: "node_modules", + Framework.LARAVEL: "vendor", + Framework.DJANGO: "__pycache__", + Framework.FASTAPI: "__pycache__", + Framework.FLASK: "__pycache__", + Framework.SPRING: "target", + Framework.ASPNET: "obj", +} def _doc_file(ctx: ProjectContext, *stems: str, directories: tuple[str, ...] = ("",)) -> str | None: diff --git a/devshield/rules/python.py b/devshield/rules/python.py new file mode 100644 index 0000000..1c1fd7f --- /dev/null +++ b/devshield/rules/python.py @@ -0,0 +1,420 @@ +from __future__ import annotations + +import ast +import re +from collections.abc import Iterator + +from devshield.core.context import ProjectContext +from devshield.core.models import Category, Finding, Framework, Severity +from devshield.core.parsers import MISSING, literal, module_assignments, secret_assignments +from devshield.core.parsers import string_literals as string_values +from devshield.core.rule import Rule +from devshield.core.search import call_arguments, is_string_literal, search, search_text +from devshield.rules._bases import is_dev_file +from devshield.rules._frameworks import ( + DOTNET_COOKIE, + SPRING_COOKIE_KEYS, + django_settings, + settings_values, + spring_config_files, +) + +DJANGO = frozenset({Framework.DJANGO}) +FLASK = frozenset({Framework.FLASK}) +PYTHON = frozenset({Framework.DJANGO, Framework.FASTAPI, Framework.FLASK}) +PYTHON_SECRET_NAMES = frozenset({"SECRET_KEY", "secret_key"}) + + +def _is_true(entry: tuple[object, int, str] | None) -> bool: + return entry is not None and literal(entry[0]) is True # type: ignore[arg-type] + + +def _is_false(entry: tuple[object, int, str] | None) -> bool: + return entry is not None and literal(entry[0]) is False # type: ignore[arg-type] + + +class DjangoAllowedHosts(Rule): + id = "SH014" + title = "Django accepts any Host header" + severity = Severity.MEDIUM + category = Category.SECURITY + applies_to = DJANGO + description = ( + "ALLOWED_HOSTS = ['*'] turns off Django's Host header validation, which enables " + "cache poisoning and password-reset poisoning attacks." + ) + remediation = "List the real host names in ALLOWED_HOSTS, for example ['example.com']." + + def check(self, ctx: ProjectContext) -> Iterator[Finding]: + for file in django_settings(ctx): + tree = ctx.pyast(file) + entry = (module_assignments(tree) if tree else {}).get("ALLOWED_HOSTS") + value = literal(entry[0]) if entry else MISSING + if isinstance(value, list | tuple) and "*" in value and entry: + yield self.finding("ALLOWED_HOSTS contains '*'", file=file, line=entry[1]) + + +class DjangoHttpsSettings(Rule): + id = "SH015" + title = "Django does not enforce HTTPS" + severity = Severity.MEDIUM + category = Category.SECURITY + applies_to = DJANGO + description = ( + "Without SECURE_SSL_REDIRECT and HSTS, browsers keep using plain HTTP and session " + "cookies and form data can be intercepted on the network." + ) + remediation = "Set SECURE_SSL_REDIRECT = True and SECURE_HSTS_SECONDS to a year (31536000)." + + def check(self, ctx: ProjectContext) -> Iterator[Finding]: + settings = settings_values(ctx) + if not settings or _is_true(settings.get("DEBUG")): + return + missing = [] + redirect = settings.get("SECURE_SSL_REDIRECT") + if redirect is None or _is_false(redirect): + missing.append("SECURE_SSL_REDIRECT") + hsts = settings.get("SECURE_HSTS_SECONDS") + if hsts is None or literal(hsts[0]) == 0: # type: ignore[arg-type] + missing.append("SECURE_HSTS_SECONDS") + if missing: + anchor = settings.get("INSTALLED_APPS") or settings.get("SECRET_KEY") + yield self.finding( + f"{' and '.join(missing)} not enabled", + file=anchor[2] if anchor else sorted(v[2] for v in settings.values())[0], + line=anchor[1] if anchor else 1, + ) + + +class CookieHardening(Rule): + id = "SH106" + title = "Session cookies not hardened" + severity = Severity.MEDIUM + category = Category.AUTHENTICATION + applies_to = PYTHON | {Framework.SPRING, Framework.ASPNET} + description = ( + "Cookies without the Secure flag travel over plain HTTP, and cookies without HttpOnly " + "can be read by injected scripts, so a single XSS bug or open network leaks sessions." + ) + remediation = "Enable the Secure and HttpOnly flags for session and CSRF cookies." + flask = re.compile( + r"""SESSION_COOKIE_(?:SECURE|HTTPONLY)['"\]]*\s*=\s*False\b""", + ) + + def check(self, ctx: ProjectContext) -> Iterator[Finding]: + yield from self._django(ctx) + for hit in search(ctx, ctx.python_files, self.flask): + yield self.finding( + f"{hit.match.group().split('=')[0].strip()} is False", file=hit.file, line=hit.line + ) + yield from self._spring(ctx) + yield from self._aspnet(ctx) + + def _django(self, ctx: ProjectContext) -> Iterator[Finding]: + if not ctx.has_framework(Framework.DJANGO): + return + settings = settings_values(ctx) + for name in ("SESSION_COOKIE_SECURE", "CSRF_COOKIE_SECURE", "SESSION_COOKIE_HTTPONLY"): + entry = settings.get(name) + if _is_false(entry) and entry: + yield self.finding(f"{name} = False", file=entry[2], line=entry[1]) + none_set = "SESSION_COOKIE_SECURE" not in settings and "CSRF_COOKIE_SECURE" not in settings + if settings and none_set and not _is_true(settings.get("DEBUG")): + anchor = settings.get("INSTALLED_APPS") or next(iter(settings.values())) + yield self.finding( + "SESSION_COOKIE_SECURE and CSRF_COOKIE_SECURE are not set", + file=anchor[2], + line=anchor[1], + severity=Severity.LOW, + ) + + def _spring(self, ctx: ProjectContext) -> Iterator[Finding]: + for file in spring_config_files(ctx): + if is_dev_file(file): + continue + for entry in ctx.config_entries(file): + key = entry.key.lower() + if key in SPRING_COOKIE_KEYS and entry.value.lower() == "false": + yield self.finding(f"{entry.key} is false", file=file, line=entry.line) + + def _aspnet(self, ctx: ProjectContext) -> Iterator[Finding]: + for hit in search(ctx, ctx.csharp_files, DOTNET_COOKIE): + yield self.finding( + "Cookie is sent without the Secure or HttpOnly flag", file=hit.file, line=hit.line + ) + + +class PermissiveCors(Rule): + id = "SH016" + title = "Permissive CORS in a Python, Java or .NET API" + severity = Severity.MEDIUM + category = Category.SECURITY + applies_to = PYTHON | {Framework.SPRING, Framework.ASPNET} + description = ( + "Allowing every origin lets any website call the API from a visitor's browser. " + "Combined with credentials it exposes authenticated sessions." + ) + remediation = "List the exact origins that need access instead of '*'." + python = re.compile( + r"""allow_origins\s*=\s*\[\s*['"]\*['"]\s*\]|\bCORS_ALLOW_ALL_ORIGINS\s*=\s*True\b""" + r"""|\bCORS_ORIGIN_ALLOW_ALL\s*=\s*True\b|\borigins\s*=\s*(?:\[\s*)?['"]\*['"]""" + ) + flask_bare = re.compile(r"\bCORS\(\s*(?:app|self\.app|application)?\s*\)") + python_credentials = re.compile( + r"allow_credentials\s*=\s*True|supports_credentials\s*=\s*True" + r"|CORS_ALLOW_CREDENTIALS\s*=\s*True" + ) + java = re.compile( + r"""@CrossOrigin(?:\(\s*\))?\s*(?:$|\n|public|class|@)|origins\s*=\s*(?:\{\s*)?"\*"|""" + r"""(?:allowedOrigins|allowedOriginPatterns|addAllowedOrigin|addAllowedOriginPattern)\(\s*"\*\"""" + ) + java_credentials = re.compile(r"allowCredentials\(\s*true|setAllowCredentials\(\s*true") + dotnet = re.compile(r"\.AllowAnyOrigin\(\)") + dotnet_credentials = re.compile(r"\.AllowCredentials\(\)") + + def check(self, ctx: ProjectContext) -> Iterator[Finding]: + yield from self._scan(ctx, ctx.python_files, [self.python], self.python_credentials) + yield from self._flask(ctx) + yield from self._scan(ctx, ctx.java_files, [self.java], self.java_credentials) + yield from self._scan(ctx, ctx.csharp_files, [self.dotnet], self.dotnet_credentials) + for file in spring_config_files(ctx): + for entry in ctx.config_entries(file): + if entry.key.lower().startswith("spring.mvc.cors") and entry.value.strip() == "*": + yield self.finding("CORS allows any origin", file=file, line=entry.line) + + def _scan( + self, + ctx: ProjectContext, + files: list[str], + patterns: list[re.Pattern[str]], + credentials: re.Pattern[str], + ) -> Iterator[Finding]: + for file in files: + text = ctx.read_text(file) or "" + with_credentials = credentials.search(text) is not None + seen: set[int] = set() + for pattern in patterns: + for hit in search(ctx, [file], pattern): + if hit.line not in seen: + seen.add(hit.line) + yield self._found(file, hit.line, with_credentials) + + def _flask(self, ctx: ProjectContext) -> Iterator[Finding]: + for file in ctx.python_files: + text = ctx.read_text(file) or "" + if "flask_cors" not in text: + continue + with_credentials = self.python_credentials.search(text) is not None + for hit in search(ctx, [file], self.flask_bare): + yield self._found(file, hit.line, with_credentials) + + def _found(self, file: str, line: int, with_credentials: bool) -> Finding: + return self.finding( + "CORS allows any origin" + (" with credentials" if with_credentials else ""), + file=file, + line=line, + severity=Severity.HIGH if with_credentials else None, + ) + + +class ApiDocsExposed(Rule): + id = "SH017" + title = "API documentation exposed" + severity = Severity.LOW + category = Category.SECURITY + applies_to = frozenset({Framework.FASTAPI, Framework.ASPNET}) + description = ( + "Interactive API documentation lists every endpoint and parameter, which helps " + "attackers map the application. It is meant for development." + ) + remediation = ( + "Disable it in production: FastAPI(docs_url=None, redoc_url=None, openapi_url=None), " + "or call UseSwagger only inside an IsDevelopment() check." + ) + fastapi = re.compile(r"\bFastAPI\(") + swagger = re.compile(r"\.UseSwagger(?:UI)?\(") + + def check(self, ctx: ProjectContext) -> Iterator[Finding]: + for hit in search(ctx, ctx.python_files, self.fastapi): + arguments = " ".join(call_arguments(ctx, hit) or []) + if not re.search(r"docs_url|openapi_url|redoc_url", arguments): + yield self.finding( + "FastAPI serves /docs and /openapi.json by default", + file=hit.file, + line=hit.line, + ) + for file in ctx.csharp_files: + text = ctx.read_text(file) or "" + if "IsDevelopment" in text or "IsEnvironment" in text: + continue + for hit in search(ctx, [file], self.swagger): + yield self.finding( + "Swagger is enabled in every environment", file=file, line=hit.line + ) + break + + +class UnsafePythonExecution(Rule): + id = "SH019" + title = "Unsafe code execution or deserialization in Python" + severity = Severity.HIGH + category = Category.SECURITY + applies_to = PYTHON + description = ( + "eval, exec, shell commands built from strings and unpickling run attacker-controlled " + "input as code whenever any part of it comes from a user or from an untrusted file." + ) + remediation = ( + "Avoid eval and exec. Pass subprocess an argument list without shell=True, use " + "json instead of pickle, and yaml.safe_load instead of yaml.load." + ) + execution = re.compile(r"(? Iterator[Finding]: + for hit in search(ctx, ctx.python_files, self.execution): + arguments = call_arguments(ctx, hit) + if arguments is None or not all(is_string_literal(a) for a in arguments): + yield self.finding( + "Code or command is built dynamically", file=hit.file, line=hit.line + ) + for hit in search(ctx, ctx.python_files, self.subprocess): + arguments = call_arguments(ctx, hit) or [] + shell = any(re.match(r"shell\s*=\s*True\b", a) for a in arguments) + positional = [a for a in arguments if "=" not in a.split("(")[0][:20]] + if shell and positional and not is_string_literal(positional[0]): + yield self.finding( + "Shell command is built dynamically", file=hit.file, line=hit.line + ) + for hit in search(ctx, ctx.python_files, self.pickle): + yield self.finding( + f"{hit.match.group().rstrip('(')} can run code from untrusted data", + file=hit.file, + line=hit.line, + severity=Severity.MEDIUM, + ) + for hit in search(ctx, ctx.python_files, self.yaml_load): + joined = " ".join(call_arguments(ctx, hit) or []) + if "Safe" not in joined: + yield self.finding( + "yaml.load without a safe loader can run code", + file=hit.file, + line=hit.line, + severity=Severity.MEDIUM, + ) + + +class CsrfDisabled(Rule): + id = "SH107" + title = "CSRF protection disabled" + severity = Severity.MEDIUM + category = Category.AUTHENTICATION + applies_to = PYTHON | {Framework.SPRING, Framework.ASPNET} + description = ( + "Without CSRF protection a malicious page can make a signed-in visitor's browser " + "submit requests to your application. Disabling it is only safe for APIs that never " + "authenticate with cookies." + ) + remediation = ( + "Keep CSRF protection on for cookie-based sessions; disable it only for stateless " + "token APIs, and scope the exemption to those endpoints." + ) + flask = re.compile(r"""WTF_CSRF_ENABLED['"\]]*\s*=\s*False\b""") + csrf_exempt = re.compile(r"^\s*@csrf_exempt\b|\bcsrf_exempt\(") + spring = re.compile( + r"\bcsrf\s*\(\s*\)\s*\.\s*disable\s*\(|\bcsrf\s*\(\s*(?:\w+\s*->\s*\w+\s*\.\s*disable\s*\(" + r"|[\w.]*::\s*disable)" + ) + dotnet = re.compile(r"\[IgnoreAntiforgeryToken\]|IgnoreAntiforgeryTokenAttribute") + + def check(self, ctx: ProjectContext) -> Iterator[Finding]: + yield from self._django_middleware(ctx) + for hit in search(ctx, ctx.python_files, self.flask): + yield self.finding("WTF_CSRF_ENABLED is False", file=hit.file, line=hit.line) + for hit in search(ctx, ctx.python_files, self.csrf_exempt): + yield self.finding( + "csrf_exempt turns CSRF protection off for this view", + file=hit.file, + line=hit.line, + severity=Severity.LOW, + ) + for hit in search_text(ctx, ctx.java_files, self.spring): + yield self.finding( + "Spring Security CSRF protection is disabled", file=hit.file, line=hit.line + ) + for hit in search(ctx, ctx.csharp_files, self.dotnet): + yield self.finding("Antiforgery validation is skipped", file=hit.file, line=hit.line) + + def _django_middleware(self, ctx: ProjectContext) -> Iterator[Finding]: + if not ctx.has_framework(Framework.DJANGO): + return + entry = settings_values(ctx).get("MIDDLEWARE") + value = literal(entry[0]) if entry else MISSING # type: ignore[arg-type] + if ( + entry + and isinstance(value, list | tuple) + and not any("CsrfViewMiddleware" in str(m) for m in value) + ): + yield self.finding( + "CsrfViewMiddleware is not in MIDDLEWARE", file=entry[2], line=entry[1] + ) + + +class HardcodedFrameworkSecret(Rule): + id = "SH206" + title = "Hard-coded framework secret key" + severity = Severity.HIGH + category = Category.SECRETS + applies_to = PYTHON + description = ( + "SECRET_KEY signs sessions, tokens and password-reset links. A key committed to the " + "repository, or left as a default fallback, lets anyone who reads the code forge them." + ) + remediation = ( + "Load the key from an environment variable or a secret manager and fail to start " + "when it is missing." + ) + + def check(self, ctx: ProjectContext) -> Iterator[Finding]: + for file in ctx.python_files: + text = ctx.read_text(file) or "" + if "secret_key" not in text.lower(): + continue + tree = ctx.pyast(file) + if tree is None or is_dev_file(file): + continue + for name, value, line in secret_assignments(tree, PYTHON_SECRET_NAMES): + literals = [s for s in string_values(value) if s.strip()] + if literals: + yield self.finding(f"{name} is a hard-coded string", file=file, line=line) + + +class DatabasePasswordInSettings(Rule): + id = "SH208" + title = "Database password written in Django settings" + severity = Severity.HIGH + category = Category.SECRETS + applies_to = DJANGO + description = ( + "A database password in settings.py is committed with the code and readable by " + "everyone with repository access." + ) + remediation = ( + "Read the password from an environment variable, for example os.environ['DB_PASSWORD']." + ) + + def check(self, ctx: ProjectContext) -> Iterator[Finding]: + for file in django_settings(ctx): + tree = ctx.pyast(file) + for node in ast.walk(tree) if tree else []: + if not isinstance(node, ast.Dict): + continue + for key, value in zip(node.keys, node.values, strict=True): + name = literal(key) if key is not None else MISSING + secret = literal(value) + if name == "PASSWORD" and isinstance(secret, str) and secret.strip(): + yield self.finding( + "DATABASES contains a literal PASSWORD", file=file, line=value.lineno + ) diff --git a/devshield/rules/sql.py b/devshield/rules/sql.py new file mode 100644 index 0000000..76ce7c3 --- /dev/null +++ b/devshield/rules/sql.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import re +from collections.abc import Iterator + +from devshield.core.context import ProjectContext +from devshield.core.models import Category, Finding, Framework, Severity +from devshield.core.rule import Rule +from devshield.core.search import search +from devshield.rules.python import PYTHON + +PYTHON_SQL = re.compile( + r"""\b(?:execute|executemany|raw|text|RawSQL)\(\s*(?:f["'][^"']*\{|["'][^"']*["']\s*""" + r"""(?:%\s*[\w(\[]|\.format\(|\+\s*[\w(]))""" +) +JAVA_SQL = re.compile( + r"""\b(?:createQuery|createNativeQuery|executeQuery|executeUpdate|prepareStatement|""" + r"""queryForObject|queryForList|queryForMap|query)\(\s*"[^"]*"\s*\+\s*[\w(]""" +) +CSHARP_SQL = re.compile( + r"""\b(?:FromSqlRaw|ExecuteSqlRaw|ExecuteSqlRawAsync|SqlQueryRaw)\(\s*(?:\$@?"[^"]*\{|"[^"]*"\s*\+)""" + r"""|new\s+(?:Sql|Npgsql|MySql|Sqlite)Command\(\s*(?:\$@?"[^"]*\{|"[^"]*"\s*\+\s*\w)""" +) + + +class SqlBuiltFromStrings(Rule): + id = "SH020" + title = "SQL query built from string formatting" + severity = Severity.HIGH + category = Category.SECURITY + applies_to = PYTHON | {Framework.SPRING, Framework.ASPNET} + description = ( + "Building a query with f-strings, concatenation or % formatting lets input change the " + "meaning of the query, which is SQL injection." + ) + remediation = ( + "Use parameterised queries: cursor.execute(sql, params), PreparedStatement with ?, or " + "FromSqlInterpolated / SqlParameter in .NET." + ) + + def check(self, ctx: ProjectContext) -> Iterator[Finding]: + groups = ( + (ctx.python_files, PYTHON_SQL, ctx.has_framework_any(PYTHON)), + (ctx.java_files, JAVA_SQL, ctx.has_framework(Framework.SPRING)), + (ctx.csharp_files, CSHARP_SQL, ctx.has_framework(Framework.ASPNET)), + ) + for files, pattern, enabled in groups: + if not enabled: + continue + for hit in search(ctx, files, pattern): + yield self.finding( + "Query is built from a formatted string", file=hit.file, line=hit.line + ) diff --git a/docs/frameworks.md b/docs/frameworks.md new file mode 100644 index 0000000..fec8255 --- /dev/null +++ b/docs/frameworks.md @@ -0,0 +1,220 @@ +# Supported frameworks + +DevShield detects the stacks in a project and runs the rules that apply to them. This page is the +map from stack to rules; each ID links to its own page. + +## How a stack is detected + +| Stack | Signals | +|---|---| +| Laravel | `artisan` file, `laravel/framework` in composer.json | +| Node.js, Express, React, Next.js | `package.json` and the matching dependency | +| NestJS | `@nestjs/core` in package.json | +| Django | `django` in requirements, pyproject or Pipfile, or a Django `manage.py` | +| FastAPI, Flask | the package in the dependencies, or an `import` in a Python file | +| Spring Boot | `spring-boot` in `pom.xml`, or the Spring Boot plugin or starters in Gradle | +| ASP.NET Core | a `.csproj` using `Microsoft.NET.Sdk.Web` or `Microsoft.AspNetCore` packages | +| Terraform | `.tf` files | +| CloudFormation | YAML or JSON templates that declare `AWS::` resources | +| Docker, GitHub Actions | `Dockerfile` or compose files, workflow files | + +Detected stacks are listed at the top of every report. Build output (`target/`, `obj/`, +`.terraform/`) and virtual environments are never scanned. + +## Rules by stack + +A rule that applies to several stacks is listed under each of them. + +### Laravel + +| Rule | Severity | Title | +|---|---|---| +| [PERF001](rules/PERF001.md) | low | Laravel optimization caches not built on deploy | +| [SH009](rules/SH009.md) | medium | Laravel CORS allows every origin | +| [SH010](rules/SH010.md) | high | Sensitive file in Laravel public directory | +| [SH104](rules/SH104.md) | medium | Laravel session cookie not hardened | +| [SH105](rules/SH105.md) | medium | No rate limiting on Laravel auth routes | + +### Node.js + +| Rule | Severity | Title | +|---|---|---| +| [SH002](rules/SH002.md) | medium | Permissive CORS configuration | +| [SH008](rules/SH008.md) | high | Dynamic code execution | +| [SH102](rules/SH102.md) | medium | Unsafe JWT usage | + +### Express + +| Rule | Severity | Title | +|---|---|---| +| [SH003](rules/SH003.md) | medium | Security headers not configured | +| [SH004](rules/SH004.md) | low | X-Powered-By header exposes Express | +| [SH101](rules/SH101.md) | medium | Insecure session cookie settings | +| [SH103](rules/SH103.md) | medium | No rate limiting on authentication routes | + +### NestJS + +| Rule | Severity | Title | +|---|---|---| +| [SH003](rules/SH003.md) | medium | Security headers not configured | +| [SH109](rules/SH109.md) | medium | NestJS request validation not enabled | + +### React + +| Rule | Severity | Title | +|---|---|---| +| [PERF201](rules/PERF201.md) | low | Production source maps enabled | +| [PERF202](rules/PERF202.md) | medium | Large frontend bundle or heavy import | +| [PERF203](rules/PERF203.md) | low | Unoptimized images | +| [SH005](rules/SH005.md) | medium | dangerouslySetInnerHTML without sanitizer | +| [SH006](rules/SH006.md) | high | Secret exposed to the browser | + +### Next.js + +| Rule | Severity | Title | +|---|---|---| +| [PERF201](rules/PERF201.md) | low | Production source maps enabled | +| [PERF202](rules/PERF202.md) | medium | Large frontend bundle or heavy import | +| [PERF203](rules/PERF203.md) | low | Unoptimized images | +| [SH005](rules/SH005.md) | medium | dangerouslySetInnerHTML without sanitizer | +| [SH006](rules/SH006.md) | high | Secret exposed to the browser | +| [SH007](rules/SH007.md) | medium | Next.js Content-Security-Policy not configured | + +### Django + +| Rule | Severity | Title | +|---|---|---| +| [SH014](rules/SH014.md) | medium | Django accepts any Host header | +| [SH015](rules/SH015.md) | medium | Django does not enforce HTTPS | +| [SH016](rules/SH016.md) | medium | Permissive CORS in a Python, Java or .NET API | +| [SH019](rules/SH019.md) | high | Unsafe code execution or deserialization in Python | +| [SH020](rules/SH020.md) | high | SQL query built from string formatting | +| [SH106](rules/SH106.md) | medium | Session cookies not hardened | +| [SH107](rules/SH107.md) | medium | CSRF protection disabled | +| [SH206](rules/SH206.md) | high | Hard-coded framework secret key | +| [SH208](rules/SH208.md) | high | Database password written in Django settings | + +### FastAPI + +| Rule | Severity | Title | +|---|---|---| +| [SH016](rules/SH016.md) | medium | Permissive CORS in a Python, Java or .NET API | +| [SH017](rules/SH017.md) | low | API documentation exposed | +| [SH019](rules/SH019.md) | high | Unsafe code execution or deserialization in Python | +| [SH020](rules/SH020.md) | high | SQL query built from string formatting | +| [SH106](rules/SH106.md) | medium | Session cookies not hardened | +| [SH107](rules/SH107.md) | medium | CSRF protection disabled | +| [SH206](rules/SH206.md) | high | Hard-coded framework secret key | + +### Flask + +| Rule | Severity | Title | +|---|---|---| +| [SH016](rules/SH016.md) | medium | Permissive CORS in a Python, Java or .NET API | +| [SH019](rules/SH019.md) | high | Unsafe code execution or deserialization in Python | +| [SH020](rules/SH020.md) | high | SQL query built from string formatting | +| [SH106](rules/SH106.md) | medium | Session cookies not hardened | +| [SH107](rules/SH107.md) | medium | CSRF protection disabled | +| [SH206](rules/SH206.md) | high | Hard-coded framework secret key | + +### Spring Boot + +| Rule | Severity | Title | +|---|---|---| +| [SH016](rules/SH016.md) | medium | Permissive CORS in a Python, Java or .NET API | +| [SH018](rules/SH018.md) | high | Spring Boot management or debug endpoints exposed | +| [SH020](rules/SH020.md) | high | SQL query built from string formatting | +| [SH106](rules/SH106.md) | medium | Session cookies not hardened | +| [SH107](rules/SH107.md) | medium | CSRF protection disabled | +| [SH108](rules/SH108.md) | high | Spring Security allows every request | +| [SH207](rules/SH207.md) | high | Credential written in an application config file | +| [SH303](rules/SH303.md) | medium | Hibernate changes the database schema at startup | + +### ASP.NET Core + +| Rule | Severity | Title | +|---|---|---| +| [SH016](rules/SH016.md) | medium | Permissive CORS in a Python, Java or .NET API | +| [SH017](rules/SH017.md) | low | API documentation exposed | +| [SH020](rules/SH020.md) | high | SQL query built from string formatting | +| [SH106](rules/SH106.md) | medium | Session cookies not hardened | +| [SH107](rules/SH107.md) | medium | CSRF protection disabled | +| [SH207](rules/SH207.md) | high | Credential written in an application config file | + +### Terraform + +| Rule | Severity | Title | +|---|---|---| +| [SH401](rules/SH401.md) | high | S3 bucket is publicly accessible | +| [SH402](rules/SH402.md) | high | IAM policy grants wildcard permissions | +| [SH403](rules/SH403.md) | high | Security group open to the internet | +| [SH404](rules/SH404.md) | medium | Storage encryption at rest not enabled | +| [SH405](rules/SH405.md) | high | Database reachable from the internet | + +### CloudFormation + +| Rule | Severity | Title | +|---|---|---| +| [SH401](rules/SH401.md) | high | S3 bucket is publicly accessible | +| [SH402](rules/SH402.md) | high | IAM policy grants wildcard permissions | +| [SH403](rules/SH403.md) | high | Security group open to the internet | +| [SH404](rules/SH404.md) | medium | Storage encryption at rest not enabled | +| [SH405](rules/SH405.md) | high | Database reachable from the internet | + +### Docker + +| Rule | Severity | Title | +|---|---|---| +| [PERF101](rules/PERF101.md) | medium | Base image not pinned to a version | +| [PERF102](rules/PERF102.md) | medium | Container runs as root | +| [PERF103](rules/PERF103.md) | low | No .dockerignore | +| [PERF104](rules/PERF104.md) | low | Build tools left in the final image | +| [PERF105](rules/PERF105.md) | low | ADD used instead of COPY | +| [PERF106](rules/PERF106.md) | low | Package install leaves caches or extras | +| [PERF107](rules/PERF107.md) | low | Dependency install after copying the whole project | +| [PERF108](rules/PERF108.md) | info | No HEALTHCHECK | +| [SH011](rules/SH011.md) | high | Risky docker-compose service configuration | +| [SH012](rules/SH012.md) | high | Secret baked into Dockerfile | + +### GitHub Actions + +| Rule | Severity | Title | +|---|---|---| +| [DX003](rules/DX003.md) | medium | Workflow token permissions too broad | +| [DX004](rules/DX004.md) | critical | pull_request_target checks out untrusted code | +| [DX005](rules/DX005.md) | medium | Action not pinned to a commit SHA | +| [DX006](rules/DX006.md) | low | CI installs dependencies without caching | +| [DX010](rules/DX010.md) | high | Secret printed in workflow logs | +| [DX011](rules/DX011.md) | high | Untrusted input interpolated into a shell step | + +### Every project + +| Rule | Severity | Title | +|---|---|---| +| [DOC001](rules/DOC001.md) | medium | README missing or nearly empty | +| [DOC002](rules/DOC002.md) | low | No license | +| [DOC003](rules/DOC003.md) | info | CONTRIBUTING or CHANGELOG missing | +| [DX001](rules/DX001.md) | medium | No automated tests detected | +| [DX002](rules/DX002.md) | low | No CI workflow | +| [DX007](rules/DX007.md) | low | Inconsistent version numbers | +| [DX008](rules/DX008.md) | low | .gitignore missing or incomplete | +| [DX009](rules/DX009.md) | low | Large file committed | +| [PERF301](rules/PERF301.md) | medium | Lockfile missing or out of sync | +| [PERF302](rules/PERF302.md) | low | Duplicate or overlapping dependencies | +| [PERF303](rules/PERF303.md) | low | Development tool in production dependencies | +| [PERF304](rules/PERF304.md) | medium | Unpinned dependency version | +| [SH001](rules/SH001.md) | high | Debug mode enabled | +| [SH013](rules/SH013.md) | medium | Dangerous file permissions | +| [SH201](rules/SH201.md) | high | Hard-coded secret | +| [SH202](rules/SH202.md) | high | .env file committed or not ignored | +| [SH203](rules/SH203.md) | critical | Private key or keystore file in project | +| [SH204](rules/SH204.md) | high | Secret in git history | +| [SH205](rules/SH205.md) | high | Weak or empty application secret | +| [SH301](rules/SH301.md) | low | .env.example missing or incomplete | +| [SH302](rules/SH302.md) | medium | Required environment variable missing | + +## Adding a stack + +Each new stack needs a detector in `devshield/core/detect.py`, a rule pack under +`devshield/rules/`, fixtures in `tests/fixtures/` (one insecure, one clean) and documentation. +Third parties can ship rules for other stacks as [plugins](plugins.md). diff --git a/docs/index.md b/docs/index.md index 1cb31ab..43fd537 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,7 +8,8 @@ pipx install devshield-cli devshield scan ``` -- **55+ rules** for Laravel, Node, Express, React, Next.js, Docker and GitHub Actions +- **75 rules** for Laravel, Node, Express, NestJS, React, Next.js, Django, FastAPI, Flask, Spring Boot, + ASP.NET Core, Terraform, CloudFormation, Docker and GitHub Actions - **Reports** as terminal, JSON, Markdown, HTML and SARIF for GitHub code scanning - **Safe auto-fix** that only edits files, so `git checkout` undoes it - **Dependency and image scanning** with OSV.dev and your local Docker diff --git a/docs/rule-ids.md b/docs/rule-ids.md index 7309c90..1998993 100644 --- a/docs/rule-ids.md +++ b/docs/rule-ids.md @@ -8,6 +8,7 @@ A prefix names the category and the number range names the subcategory. | `SH` | 100–199 | Authentication, sessions, JWT | | `SH` | 200–299 | Secrets | | `SH` | 300–399 | Configuration and environment | +| `SH` | 400–499 | Cloud and infrastructure as code (Terraform, CloudFormation) | | `PERF` | 001–099 | General performance | | `PERF` | 100–199 | Docker | | `PERF` | 200–299 | Frontend | @@ -15,6 +16,9 @@ A prefix names the category and the number range names the subcategory. | `DX` | 001–099 | Developer experience, project health, CI | | `DOC` | 001–099 | Documentation | +Framework rules use the same ranges as general rules: a Django, Spring or ASP.NET check +that concerns cookies is an `SH1xx` rule, one that concerns secrets is an `SH2xx` rule. + Plugin rules must use their own prefix (for example `ACME001`). Built-in prefixes are reserved. diff --git a/docs/rules/README.md b/docs/rules/README.md index b405915..3bd518d 100644 --- a/docs/rules/README.md +++ b/docs/rules/README.md @@ -45,15 +45,35 @@ | [SH011](SH011.md) | high | security | Risky docker-compose service configuration | | | [SH012](SH012.md) | high | security | Secret baked into Dockerfile | | | [SH013](SH013.md) | medium | security | Dangerous file permissions | | +| [SH014](SH014.md) | medium | security | Django accepts any Host header | | +| [SH015](SH015.md) | medium | security | Django does not enforce HTTPS | | +| [SH016](SH016.md) | medium | security | Permissive CORS in a Python, Java or .NET API | | +| [SH017](SH017.md) | low | security | API documentation exposed | | +| [SH018](SH018.md) | high | security | Spring Boot management or debug endpoints exposed | | +| [SH019](SH019.md) | high | security | Unsafe code execution or deserialization in Python | | +| [SH020](SH020.md) | high | security | SQL query built from string formatting | | | [SH101](SH101.md) | medium | authentication | Insecure session cookie settings | | | [SH102](SH102.md) | medium | authentication | Unsafe JWT usage | | | [SH103](SH103.md) | medium | authentication | No rate limiting on authentication routes | | | [SH104](SH104.md) | medium | authentication | Laravel session cookie not hardened | | | [SH105](SH105.md) | medium | authentication | No rate limiting on Laravel auth routes | | +| [SH106](SH106.md) | medium | authentication | Session cookies not hardened | | +| [SH107](SH107.md) | medium | authentication | CSRF protection disabled | | +| [SH108](SH108.md) | high | authentication | Spring Security allows every request | | +| [SH109](SH109.md) | medium | authentication | NestJS request validation not enabled | | | [SH201](SH201.md) | high | secrets | Hard-coded secret | | | [SH202](SH202.md) | high | secrets | .env file committed or not ignored | yes | | [SH203](SH203.md) | critical | secrets | Private key or keystore file in project | | | [SH204](SH204.md) | high | secrets | Secret in git history | | | [SH205](SH205.md) | high | secrets | Weak or empty application secret | | +| [SH206](SH206.md) | high | secrets | Hard-coded framework secret key | | +| [SH207](SH207.md) | high | secrets | Credential written in an application config file | | +| [SH208](SH208.md) | high | secrets | Database password written in Django settings | | | [SH301](SH301.md) | low | configuration | .env.example missing or incomplete | yes | | [SH302](SH302.md) | medium | configuration | Required environment variable missing | | +| [SH303](SH303.md) | medium | configuration | Hibernate changes the database schema at startup | | +| [SH401](SH401.md) | high | security | S3 bucket is publicly accessible | | +| [SH402](SH402.md) | high | security | IAM policy grants wildcard permissions | | +| [SH403](SH403.md) | high | security | Security group open to the internet | | +| [SH404](SH404.md) | medium | security | Storage encryption at rest not enabled | | +| [SH405](SH405.md) | high | security | Database reachable from the internet | | diff --git a/docs/rules/SH003.md b/docs/rules/SH003.md index 14b61ec..3d519b6 100644 --- a/docs/rules/SH003.md +++ b/docs/rules/SH003.md @@ -1,6 +1,6 @@ # SH003: Security headers not configured -**Severity:** medium · **Category:** security · **Applies to:** express +**Severity:** medium · **Category:** security · **Applies to:** express, nestjs ## Why it matters @@ -8,7 +8,7 @@ Headers such as Content-Security-Policy and Strict-Transport-Security block whol ## How to fix -Install helmet and add `app.use(helmet())` before your routes. +Install helmet and add `app.use(helmet())` before your routes (in NestJS, in main.ts). ## Ignoring this rule diff --git a/docs/rules/SH014.md b/docs/rules/SH014.md new file mode 100644 index 0000000..fc0b1f5 --- /dev/null +++ b/docs/rules/SH014.md @@ -0,0 +1,26 @@ +# SH014: Django accepts any Host header + +**Severity:** medium · **Category:** security · **Applies to:** django + +## Why it matters + +ALLOWED_HOSTS = ['*'] turns off Django's Host header validation, which enables cache poisoning and password-reset poisoning attacks. + +## How to fix + +List the real host names in ALLOWED_HOSTS, for example ['example.com']. + +## Ignoring this rule + +Skip it everywhere in `devshield.yml`: + +```yaml +ignore: + - SH014 +``` + +Or skip one line with a comment on that line or the line above: + +```text +devshield-ignore: SH014 +``` diff --git a/docs/rules/SH015.md b/docs/rules/SH015.md new file mode 100644 index 0000000..7ab92fe --- /dev/null +++ b/docs/rules/SH015.md @@ -0,0 +1,26 @@ +# SH015: Django does not enforce HTTPS + +**Severity:** medium · **Category:** security · **Applies to:** django + +## Why it matters + +Without SECURE_SSL_REDIRECT and HSTS, browsers keep using plain HTTP and session cookies and form data can be intercepted on the network. + +## How to fix + +Set SECURE_SSL_REDIRECT = True and SECURE_HSTS_SECONDS to a year (31536000). + +## Ignoring this rule + +Skip it everywhere in `devshield.yml`: + +```yaml +ignore: + - SH015 +``` + +Or skip one line with a comment on that line or the line above: + +```text +devshield-ignore: SH015 +``` diff --git a/docs/rules/SH016.md b/docs/rules/SH016.md new file mode 100644 index 0000000..44bd971 --- /dev/null +++ b/docs/rules/SH016.md @@ -0,0 +1,26 @@ +# SH016: Permissive CORS in a Python, Java or .NET API + +**Severity:** medium · **Category:** security · **Applies to:** aspnet, django, fastapi, flask, spring + +## Why it matters + +Allowing every origin lets any website call the API from a visitor's browser. Combined with credentials it exposes authenticated sessions. + +## How to fix + +List the exact origins that need access instead of '*'. + +## Ignoring this rule + +Skip it everywhere in `devshield.yml`: + +```yaml +ignore: + - SH016 +``` + +Or skip one line with a comment on that line or the line above: + +```text +devshield-ignore: SH016 +``` diff --git a/docs/rules/SH017.md b/docs/rules/SH017.md new file mode 100644 index 0000000..518ca09 --- /dev/null +++ b/docs/rules/SH017.md @@ -0,0 +1,26 @@ +# SH017: API documentation exposed + +**Severity:** low · **Category:** security · **Applies to:** aspnet, fastapi + +## Why it matters + +Interactive API documentation lists every endpoint and parameter, which helps attackers map the application. It is meant for development. + +## How to fix + +Disable it in production: FastAPI(docs_url=None, redoc_url=None, openapi_url=None), or call UseSwagger only inside an IsDevelopment() check. + +## Ignoring this rule + +Skip it everywhere in `devshield.yml`: + +```yaml +ignore: + - SH017 +``` + +Or skip one line with a comment on that line or the line above: + +```text +devshield-ignore: SH017 +``` diff --git a/docs/rules/SH018.md b/docs/rules/SH018.md new file mode 100644 index 0000000..8f62cd1 --- /dev/null +++ b/docs/rules/SH018.md @@ -0,0 +1,26 @@ +# SH018: Spring Boot management or debug endpoints exposed + +**Severity:** high · **Category:** security · **Applies to:** spring + +## Why it matters + +Actuator endpoints such as env, heapdump and beans reveal configuration, secrets in memory and the application's structure, and /shutdown can stop the service. The H2 console and stack traces in error responses hand attackers the same kind of detail. + +## How to fix + +Expose only health and info over HTTP, put the rest behind authentication or a separate management port, and turn the H2 console and stack traces off. + +## Ignoring this rule + +Skip it everywhere in `devshield.yml`: + +```yaml +ignore: + - SH018 +``` + +Or skip one line with a comment on that line or the line above: + +```text +devshield-ignore: SH018 +``` diff --git a/docs/rules/SH019.md b/docs/rules/SH019.md new file mode 100644 index 0000000..e4384cc --- /dev/null +++ b/docs/rules/SH019.md @@ -0,0 +1,26 @@ +# SH019: Unsafe code execution or deserialization in Python + +**Severity:** high · **Category:** security · **Applies to:** django, fastapi, flask + +## Why it matters + +eval, exec, shell commands built from strings and unpickling run attacker-controlled input as code whenever any part of it comes from a user or from an untrusted file. + +## How to fix + +Avoid eval and exec. Pass subprocess an argument list without shell=True, use json instead of pickle, and yaml.safe_load instead of yaml.load. + +## Ignoring this rule + +Skip it everywhere in `devshield.yml`: + +```yaml +ignore: + - SH019 +``` + +Or skip one line with a comment on that line or the line above: + +```text +devshield-ignore: SH019 +``` diff --git a/docs/rules/SH020.md b/docs/rules/SH020.md new file mode 100644 index 0000000..0603bf5 --- /dev/null +++ b/docs/rules/SH020.md @@ -0,0 +1,26 @@ +# SH020: SQL query built from string formatting + +**Severity:** high · **Category:** security · **Applies to:** aspnet, django, fastapi, flask, spring + +## Why it matters + +Building a query with f-strings, concatenation or % formatting lets input change the meaning of the query, which is SQL injection. + +## How to fix + +Use parameterised queries: cursor.execute(sql, params), PreparedStatement with ?, or FromSqlInterpolated / SqlParameter in .NET. + +## Ignoring this rule + +Skip it everywhere in `devshield.yml`: + +```yaml +ignore: + - SH020 +``` + +Or skip one line with a comment on that line or the line above: + +```text +devshield-ignore: SH020 +``` diff --git a/docs/rules/SH106.md b/docs/rules/SH106.md new file mode 100644 index 0000000..a3bb7f8 --- /dev/null +++ b/docs/rules/SH106.md @@ -0,0 +1,26 @@ +# SH106: Session cookies not hardened + +**Severity:** medium · **Category:** authentication · **Applies to:** aspnet, django, fastapi, flask, spring + +## Why it matters + +Cookies without the Secure flag travel over plain HTTP, and cookies without HttpOnly can be read by injected scripts, so a single XSS bug or open network leaks sessions. + +## How to fix + +Enable the Secure and HttpOnly flags for session and CSRF cookies. + +## Ignoring this rule + +Skip it everywhere in `devshield.yml`: + +```yaml +ignore: + - SH106 +``` + +Or skip one line with a comment on that line or the line above: + +```text +devshield-ignore: SH106 +``` diff --git a/docs/rules/SH107.md b/docs/rules/SH107.md new file mode 100644 index 0000000..f7b7c2c --- /dev/null +++ b/docs/rules/SH107.md @@ -0,0 +1,26 @@ +# SH107: CSRF protection disabled + +**Severity:** medium · **Category:** authentication · **Applies to:** aspnet, django, fastapi, flask, spring + +## Why it matters + +Without CSRF protection a malicious page can make a signed-in visitor's browser submit requests to your application. Disabling it is only safe for APIs that never authenticate with cookies. + +## How to fix + +Keep CSRF protection on for cookie-based sessions; disable it only for stateless token APIs, and scope the exemption to those endpoints. + +## Ignoring this rule + +Skip it everywhere in `devshield.yml`: + +```yaml +ignore: + - SH107 +``` + +Or skip one line with a comment on that line or the line above: + +```text +devshield-ignore: SH107 +``` diff --git a/docs/rules/SH108.md b/docs/rules/SH108.md new file mode 100644 index 0000000..5dc3593 --- /dev/null +++ b/docs/rules/SH108.md @@ -0,0 +1,26 @@ +# SH108: Spring Security allows every request + +**Severity:** high · **Category:** authentication · **Applies to:** spring + +## Why it matters + +permitAll() on every request, or on /**, switches authentication and authorization off for the whole application. + +## How to fix + +End the rule chain with anyRequest().authenticated() and permit only the specific public paths, such as the login page and static assets. + +## Ignoring this rule + +Skip it everywhere in `devshield.yml`: + +```yaml +ignore: + - SH108 +``` + +Or skip one line with a comment on that line or the line above: + +```text +devshield-ignore: SH108 +``` diff --git a/docs/rules/SH109.md b/docs/rules/SH109.md new file mode 100644 index 0000000..4944042 --- /dev/null +++ b/docs/rules/SH109.md @@ -0,0 +1,26 @@ +# SH109: NestJS request validation not enabled + +**Severity:** medium · **Category:** authentication · **Applies to:** nestjs + +## Why it matters + +Without a ValidationPipe, the decorators on your DTO classes are ignored and request bodies reach your handlers unchecked, which invites injection and mass-assignment bugs. + +## How to fix + +Add `app.useGlobalPipes(new ValidationPipe({ whitelist: true }))` in main.ts, or register it with the APP_PIPE provider. + +## Ignoring this rule + +Skip it everywhere in `devshield.yml`: + +```yaml +ignore: + - SH109 +``` + +Or skip one line with a comment on that line or the line above: + +```text +devshield-ignore: SH109 +``` diff --git a/docs/rules/SH206.md b/docs/rules/SH206.md new file mode 100644 index 0000000..077d7ff --- /dev/null +++ b/docs/rules/SH206.md @@ -0,0 +1,26 @@ +# SH206: Hard-coded framework secret key + +**Severity:** high · **Category:** secrets · **Applies to:** django, fastapi, flask + +## Why it matters + +SECRET_KEY signs sessions, tokens and password-reset links. A key committed to the repository, or left as a default fallback, lets anyone who reads the code forge them. + +## How to fix + +Load the key from an environment variable or a secret manager and fail to start when it is missing. + +## Ignoring this rule + +Skip it everywhere in `devshield.yml`: + +```yaml +ignore: + - SH206 +``` + +Or skip one line with a comment on that line or the line above: + +```text +devshield-ignore: SH206 +``` diff --git a/docs/rules/SH207.md b/docs/rules/SH207.md new file mode 100644 index 0000000..2eed6ca --- /dev/null +++ b/docs/rules/SH207.md @@ -0,0 +1,26 @@ +# SH207: Credential written in an application config file + +**Severity:** high · **Category:** secrets · **Applies to:** aspnet, spring + +## Why it matters + +Passwords, API keys and signing keys in application.properties, application.yml or appsettings.json are committed with the code and readable by everyone with access to the repository. + +## How to fix + +Reference an environment variable (${DB_PASSWORD} in Spring, environment variables or user-secrets in .NET) or a secret manager, and rotate the exposed value. + +## Ignoring this rule + +Skip it everywhere in `devshield.yml`: + +```yaml +ignore: + - SH207 +``` + +Or skip one line with a comment on that line or the line above: + +```text +devshield-ignore: SH207 +``` diff --git a/docs/rules/SH208.md b/docs/rules/SH208.md new file mode 100644 index 0000000..5020610 --- /dev/null +++ b/docs/rules/SH208.md @@ -0,0 +1,26 @@ +# SH208: Database password written in Django settings + +**Severity:** high · **Category:** secrets · **Applies to:** django + +## Why it matters + +A database password in settings.py is committed with the code and readable by everyone with repository access. + +## How to fix + +Read the password from an environment variable, for example os.environ['DB_PASSWORD']. + +## Ignoring this rule + +Skip it everywhere in `devshield.yml`: + +```yaml +ignore: + - SH208 +``` + +Or skip one line with a comment on that line or the line above: + +```text +devshield-ignore: SH208 +``` diff --git a/docs/rules/SH303.md b/docs/rules/SH303.md new file mode 100644 index 0000000..baf60a5 --- /dev/null +++ b/docs/rules/SH303.md @@ -0,0 +1,26 @@ +# SH303: Hibernate changes the database schema at startup + +**Severity:** medium · **Category:** configuration · **Applies to:** spring + +## Why it matters + +ddl-auto set to create, create-drop or update lets the application drop or alter tables on startup, which can destroy production data. + +## How to fix + +Set spring.jpa.hibernate.ddl-auto to validate or none and manage the schema with Flyway or Liquibase. + +## Ignoring this rule + +Skip it everywhere in `devshield.yml`: + +```yaml +ignore: + - SH303 +``` + +Or skip one line with a comment on that line or the line above: + +```text +devshield-ignore: SH303 +``` diff --git a/docs/rules/SH401.md b/docs/rules/SH401.md new file mode 100644 index 0000000..2f33d7b --- /dev/null +++ b/docs/rules/SH401.md @@ -0,0 +1,26 @@ +# SH401: S3 bucket is publicly accessible + +**Severity:** high · **Category:** security · **Applies to:** cloudformation, terraform + +## Why it matters + +A public S3 bucket lets anyone on the internet read, and sometimes write, its objects. Public buckets are a leading cause of data leaks. + +## How to fix + +Use a private ACL, enable all four settings of the public access block, and grant access with specific principals or CloudFront origin access control. + +## Ignoring this rule + +Skip it everywhere in `devshield.yml`: + +```yaml +ignore: + - SH401 +``` + +Or skip one line with a comment on that line or the line above: + +```text +devshield-ignore: SH401 +``` diff --git a/docs/rules/SH402.md b/docs/rules/SH402.md new file mode 100644 index 0000000..98c83c7 --- /dev/null +++ b/docs/rules/SH402.md @@ -0,0 +1,26 @@ +# SH402: IAM policy grants wildcard permissions + +**Severity:** high · **Category:** security · **Applies to:** cloudformation, terraform + +## Why it matters + +An Allow statement with Action '*' grants every API action, and with Resource '*' as well it is full administrator access. A leaked credential then controls the account. + +## How to fix + +List the specific actions and resources the workload needs (least privilege). + +## Ignoring this rule + +Skip it everywhere in `devshield.yml`: + +```yaml +ignore: + - SH402 +``` + +Or skip one line with a comment on that line or the line above: + +```text +devshield-ignore: SH402 +``` diff --git a/docs/rules/SH403.md b/docs/rules/SH403.md new file mode 100644 index 0000000..ae34e4b --- /dev/null +++ b/docs/rules/SH403.md @@ -0,0 +1,26 @@ +# SH403: Security group open to the internet + +**Severity:** high · **Category:** security · **Applies to:** cloudformation, terraform + +## Why it matters + +Ingress from 0.0.0.0/0 to SSH, RDP, databases or every port exposes those services to constant scanning and brute-force attempts. + +## How to fix + +Restrict the source to your own CIDR range or a security group, and use SSM Session Manager or a VPN instead of public SSH. + +## Ignoring this rule + +Skip it everywhere in `devshield.yml`: + +```yaml +ignore: + - SH403 +``` + +Or skip one line with a comment on that line or the line above: + +```text +devshield-ignore: SH403 +``` diff --git a/docs/rules/SH404.md b/docs/rules/SH404.md new file mode 100644 index 0000000..02f1888 --- /dev/null +++ b/docs/rules/SH404.md @@ -0,0 +1,26 @@ +# SH404: Storage encryption at rest not enabled + +**Severity:** medium · **Category:** security · **Applies to:** cloudformation, terraform + +## Why it matters + +Unencrypted databases and volumes expose their data if a snapshot, a disk or a backup is copied or shared by mistake, and many compliance regimes require encryption. + +## How to fix + +Set storage_encrypted = true (RDS) or encrypted = true (EBS), ideally with a KMS key. + +## Ignoring this rule + +Skip it everywhere in `devshield.yml`: + +```yaml +ignore: + - SH404 +``` + +Or skip one line with a comment on that line or the line above: + +```text +devshield-ignore: SH404 +``` diff --git a/docs/rules/SH405.md b/docs/rules/SH405.md new file mode 100644 index 0000000..90498a7 --- /dev/null +++ b/docs/rules/SH405.md @@ -0,0 +1,26 @@ +# SH405: Database reachable from the internet + +**Severity:** high · **Category:** security · **Applies to:** cloudformation, terraform + +## Why it matters + +A database with a public address is exposed to the internet and to brute-force and exploit attempts, even when a security group limits who can connect. + +## How to fix + +Set publicly_accessible = false and reach the database through a private subnet, a VPN or a bastion host. + +## Ignoring this rule + +Skip it everywhere in `devshield.yml`: + +```yaml +ignore: + - SH405 +``` + +Or skip one line with a comment on that line or the line above: + +```text +devshield-ignore: SH405 +``` diff --git a/docs/schema/scan-result.schema.json b/docs/schema/scan-result.schema.json index bf9a447..7f24248 100644 --- a/docs/schema/scan-result.schema.json +++ b/docs/schema/scan-result.schema.json @@ -116,7 +116,15 @@ "react", "nextjs", "docker", - "github_actions" + "github_actions", + "django", + "fastapi", + "flask", + "nestjs", + "spring", + "aspnet", + "terraform", + "cloudformation" ], "title": "Framework", "type": "string" diff --git a/mkdocs.yml b/mkdocs.yml index 00c07a4..44f517c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -32,6 +32,7 @@ nav: - Configuration: configuration.md - CI guides: ci.md - Reference: + - Supported frameworks: frameworks.md - Rules: rules/README.md - Rule IDs: rule-ids.md - Scoring: scoring.md diff --git a/pyproject.toml b/pyproject.toml index 85c9d7b..b3728b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,6 +64,7 @@ packages = ["devshield"] line-length = 100 target-version = "py311" src = ["devshield", "tests"] +extend-exclude = ["tests/fixtures"] [tool.ruff.lint] select = ["E", "F", "W", "I", "B", "UP", "SIM", "C4", "PTH", "RUF", "S"] diff --git a/scripts/gen_rule_docs.py b/scripts/gen_rule_docs.py index 40fd6c4..a808d5a 100644 --- a/scripts/gen_rule_docs.py +++ b/scripts/gen_rule_docs.py @@ -1,9 +1,12 @@ from pathlib import Path from devshield.core.registry import default_registry -from devshield.reports.rule_docs import render_all +from devshield.reports.rule_docs import render_all, render_frameworks_page target = Path(__file__).resolve().parent.parent / "docs" / "rules" target.mkdir(parents=True, exist_ok=True) for name, content in render_all(default_registry().all()).items(): (target / name).write_text(content, encoding="utf-8") + +frameworks_page = target.parent / "frameworks.md" +frameworks_page.write_text(render_frameworks_page(default_registry().all()), encoding="utf-8") diff --git a/tests/core/test_framework_support.py b/tests/core/test_framework_support.py new file mode 100644 index 0000000..566690a --- /dev/null +++ b/tests/core/test_framework_support.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +import json + +import pytest +from devshield.core.context import ProjectContext, is_test_path +from devshield.core.models import Confidence, Framework +from devshield.core.parsers import ( + flatten_json, + flatten_yaml, + load_template, + module_assignments, + parse_hcl, + parse_module, + parse_pipfile, + parse_properties, + parse_pyproject, + parse_requirements_txt, + secret_assignments, + string_literals, +) + +from tests.conftest import MakeProject + + +def detected(root: object) -> dict[Framework, Confidence]: + return {d.framework: d.confidence for d in ProjectContext(root).frameworks} # type: ignore[arg-type] + + +class TestDetection: + def test_django_by_dependency_and_manage_py(self, make_project: MakeProject) -> None: + root = make_project( + {"requirements.txt": "Django>=5\n", "manage.py": "DJANGO_SETTINGS_MODULE = 'x'\n"} + ) + assert detected(root) == {Framework.DJANGO: Confidence.HIGH} + + def test_django_with_one_signal_is_medium(self, make_project: MakeProject) -> None: + assert detected( + make_project({"pyproject.toml": '[project]\ndependencies = ["django"]\n'}) + ) == {Framework.DJANGO: Confidence.MEDIUM} + + def test_fastapi_and_flask_by_dependency_or_import(self, make_project: MakeProject) -> None: + root = make_project( + {"requirements.txt": "fastapi\nflask\n", "app.py": "from flask import Flask\n"} + ) + found = detected(root) + assert found[Framework.FASTAPI] is Confidence.MEDIUM + assert found[Framework.FLASK] is Confidence.HIGH + + def test_python_import_alone_is_enough(self, make_project: MakeProject) -> None: + assert Framework.FASTAPI in detected(make_project({"main.py": "import fastapi\n"})) + + def test_comments_and_strings_do_not_count(self, make_project: MakeProject) -> None: + root = make_project({"a.py": "# from flask import Flask\nx = 'import flask'\n"}) + assert detected(root) == {} + + def test_nestjs(self, make_project: MakeProject) -> None: + root = make_project({"package.json": '{"dependencies": {"@nestjs/core": "10"}}'}) + assert set(detected(root)) == {Framework.NODE, Framework.NESTJS} + + @pytest.mark.parametrize( + "files", + [ + {"pom.xml": " spring-boot-starter-parent "}, + {"build.gradle": "plugins { id 'org.springframework.boot' version '3.2.0' }"}, + { + "build.gradle.kts": 'implementation("org.springframework.boot:spring-boot-starter-web")' + }, + ], + ) + def test_spring_boot(self, make_project: MakeProject, files: dict[str, str]) -> None: + assert Framework.SPRING in detected(make_project(files)) + + def test_plain_maven_project_is_not_spring(self, make_project: MakeProject) -> None: + assert detected(make_project({"pom.xml": ""})) == {} + + def test_aspnet_core(self, make_project: MakeProject) -> None: + sdk = make_project({"A.csproj": ' '}) + assert Framework.ASPNET in detected(sdk) + + def test_console_dotnet_project_is_not_aspnet(self, make_project: MakeProject) -> None: + assert detected(make_project({"A.csproj": ' '})) == {} + + def test_terraform_and_cloudformation(self, make_project: MakeProject) -> None: + root = make_project( + { + "infra/main.tf": 'resource "aws_s3_bucket" "b" {}\n', + "stack.yml": "Resources:\n B:\n Type: AWS::S3::Bucket\n", + "other.json": '{"Resources": 1}', + "package-lock.json": '{"Resources": "AWS::"}', + } + ) + found = detected(root) + assert set(found) == {Framework.TERRAFORM, Framework.CLOUDFORMATION} + assert ProjectContext(root).cloudformation_files == ["stack.yml"] + + def test_workflow_files_are_not_cloudformation(self, make_project: MakeProject) -> None: + root = make_project( + {".github/workflows/ci.yml": "name: x\njobs:\n a:\n steps: [Resources AWS::S3]\n"} + ) + assert Framework.CLOUDFORMATION not in detected(root) + + def test_build_output_directories_are_skipped(self, make_project: MakeProject) -> None: + root = make_project( + { + "target/classes/application.properties": "x=1\n", + ".terraform/modules/m/main.tf": 'resource "a" "b" {}\n', + "obj/Debug/Gen.cs": "class A {}\n", + } + ) + assert ProjectContext(root).files == () + + +def test_test_paths_for_the_new_ecosystems() -> None: + for path in ( + "tests/test_a.py", + "conftest.py", + "src/test/java/com/A.java", + "src/main/java/AServiceTest.java", + "App.Tests/Calc.cs", + "CalcTests.cs", + ): + assert is_test_path(path), path + for path in ("src/main/java/Service.java", "App/Calc.cs", "app/models.py"): + assert not is_test_path(path), path + + +class TestParsers: + def test_requirements_txt(self) -> None: + text = ( + "# comment\n-r base.txt\nDjango[argon2]>=5.0 ; python_version > '3.9'\n" + "flask_cors==4.0 # inline\n-e .\ngit+https://x/y.git\n" + ) + assert parse_requirements_txt(text) == {"django": ">=5.0", "flask-cors": "==4.0"} + + def test_pyproject_formats(self) -> None: + pep621 = ( + '[project]\ndependencies = ["FastAPI>=0.1", "uvicorn"]\n' + '[project.optional-dependencies]\ndev = ["pytest"]\n' + ) + poetry = '[tool.poetry.dependencies]\npython = "^3.11"\nFlask = "^3"\n[tool.poetry.group.dev.dependencies]\nblack = "*"\n' + assert set(parse_pyproject(pep621)) == {"fastapi", "uvicorn", "pytest"} + assert set(parse_pyproject(poetry)) == {"flask", "black"} + assert parse_pyproject("not [toml") == {} + + def test_pipfile(self) -> None: + assert set(parse_pipfile('[packages]\nDjango = "*"\n[dev-packages]\npytest = "*"\n')) == { + "django", + "pytest", + } + + def test_properties(self) -> None: + text = "# c\n! c\na.b=1\nc.d : two words\n\ne = \nbad line\n" + assert [(e.key, e.value, e.line) for e in parse_properties(text)] == [ + ("a.b", "1", 3), + ("c.d", "two words", 4), + ("e", "", 6), + ] + + def test_yaml_is_flattened_with_lines_across_documents(self) -> None: + text = "a:\n b: 1\n c:\n - x\n - y\n---\nd: 2\n" + assert [(e.key, e.value, e.line) for e in flatten_yaml(text)] == [ + ("a.b", "1", 2), + ("a.c[0]", "x", 4), + ("a.c[1]", "y", 5), + ("d", "2", 7), + ] + assert flatten_yaml("a: [") == [] + + def test_json_is_flattened_with_lines(self) -> None: + text = json.dumps({"A": {"B": "x", "C": True}, "D": [1]}, indent=2) + assert [(e.key, e.value, e.line) for e in flatten_json(text)] == [ + ("A:B", "x", 3), + ("A:C", "true", 4), + ("D:0", "1", 7), + ] + assert flatten_json("{") == [] + + def test_hcl_blocks_attributes_and_nested_blocks(self) -> None: + text = ( + '# top\nresource "aws_s3_bucket" "b" {\n bucket = "x" # trailing\n' + ' tags = {\n a = "b"\n }\n versioning {\n enabled = true\n }\n' + ' policy = < None: + template = load_template( + "Resources:\n B:\n Type: AWS::S3::Bucket\n Properties:\n" + " Name: !Sub 'x-${A}'\n Arn: !GetAtt [B, Arn]\n Ref: !Ref A\n" + ) + assert template is not None + assert template["Resources"]["B"]["Properties"]["Arn"] == ["B", "Arn"] + assert load_template('{"Resources": {}}', is_json=True) == {"Resources": {}} + assert load_template("- a\n- b") is None and load_template("a: [") is None + + def test_python_ast_helpers(self) -> None: + tree = parse_module( + "A = 1\nB: str = 'x'\nA = 2\nclass C:\n SECRET_KEY = os.environ.get('K') or 'fallback'\n" + "app.secret_key = 'dev'\napp.config['SECRET_KEY'] = 'z'\nSECRET_KEY = os.environ['K']\n" + ) + assert tree is not None + assigned = module_assignments(tree) + assert assigned["A"][1] == 3 and assigned["B"][1] == 2 + found = secret_assignments(tree, frozenset({"SECRET_KEY", "secret_key"})) + literals = {line: string_literals(value) for _, value, line in found} + assert literals == {5: ["fallback"], 6: ["dev"], 7: ["z"], 8: []} + assert parse_module("def (") is None diff --git a/tests/fixers/test_fixers.py b/tests/fixers/test_fixers.py index 67fb4f1..5418cca 100644 --- a/tests/fixers/test_fixers.py +++ b/tests/fixers/test_fixers.py @@ -16,7 +16,15 @@ from typer.testing import CliRunner from tests.conftest import FIXTURES, MakeProject -from tests.rules.helpers import EXPRESS, LARAVEL, NODE, REGISTRY, WORKFLOW +from tests.rules.helpers import ( + ASPNET, + EXPRESS, + LARAVEL, + NODE, + REGISTRY, + SPRING, + WORKFLOW, +) def scan(root: Path, *rules: str): # type: ignore[no-untyped-def] @@ -97,6 +105,17 @@ def test_sets_false_and_keeps_the_rest_of_the_line(self, make_project: MakeProje 'APP_NAME=x\nexport APP_DEBUG="false" # temp\nAPP_ENV=production\n' ) + @pytest.mark.parametrize( + ("key", "value", "expected"), + [("FLASK_DEBUG", "1", "FLASK_DEBUG=false"), ("DJANGO_DEBUG", "True", "DJANGO_DEBUG=false")], + ) + def test_python_debug_flags( + self, make_project: MakeProject, key: str, value: str, expected: str + ) -> None: + root = make_project({".env.production": f"{key}={value}\n"}) + fix_and_verify(root, "SH001") + assert read(root, ".env.production") == f"{expected}\n" + def test_other_places_are_reported_but_not_edited(self, make_project: MakeProject) -> None: root = make_project( { @@ -256,6 +275,24 @@ def test_creates_a_workflow_that_passes_the_ci_rules( assert yaml.safe_load(read(root, ".github/workflows/ci.yml"))["jobs"] assert scan(root, "DX003", "DX004", "DX005", "DX006", "DX010", "DX011").findings == () + @pytest.mark.parametrize( + ("files", "expected"), + [ + ({"requirements.txt": "Django\n"}, "pip install -r requirements.txt pytest"), + ({"pyproject.toml": '[project]\ndependencies = ["flask"]\n'}, "pip install . pytest"), + ({**SPRING}, "mvn -B verify"), + ({"build.gradle": "id 'org.springframework.boot'"}, "./gradlew build"), + ({**ASPNET}, "dotnet test --no-build"), + ], + ) + def test_other_ecosystems_get_a_matching_workflow( + self, make_project: MakeProject, files: dict[str, str], expected: str + ) -> None: + root = make_project(files) + fix_and_verify(root, "DX002") + assert expected in read(root, ".github/workflows/ci.yml") + assert scan(root, "DX003", "DX004", "DX005", "DX006", "DX010", "DX011").findings == () + def test_never_overwrites_an_existing_file(self, make_project: MakeProject) -> None: root = make_project({"package.json": NODE, ".github/workflows/ci.yml": "on: push\n"}) assert scan(root, "DX002").findings == () diff --git a/tests/fixtures/aspnet-clean/.github/workflows/ci.yml b/tests/fixtures/aspnet-clean/.github/workflows/ci.yml new file mode 100644 index 0000000..eb9726f --- /dev/null +++ b/tests/fixtures/aspnet-clean/.github/workflows/ci.yml @@ -0,0 +1,11 @@ +name: CI +on: pull_request +permissions: + contents: read +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 + - uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 + - run: dotnet test diff --git a/tests/fixtures/aspnet-clean/.gitignore b/tests/fixtures/aspnet-clean/.gitignore new file mode 100644 index 0000000..cd42ee3 --- /dev/null +++ b/tests/fixtures/aspnet-clean/.gitignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/tests/fixtures/aspnet-clean/CHANGELOG.md b/tests/fixtures/aspnet-clean/CHANGELOG.md new file mode 100644 index 0000000..58f70e6 --- /dev/null +++ b/tests/fixtures/aspnet-clean/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## [Unreleased] + +- Initial version. diff --git a/tests/fixtures/aspnet-clean/CONTRIBUTING.md b/tests/fixtures/aspnet-clean/CONTRIBUTING.md new file mode 100644 index 0000000..664326f --- /dev/null +++ b/tests/fixtures/aspnet-clean/CONTRIBUTING.md @@ -0,0 +1,3 @@ +# Contributing + +Open an issue first, keep changes small, and run the tests before sending a pull request. diff --git a/tests/fixtures/aspnet-clean/Controllers/UsersController.cs b/tests/fixtures/aspnet-clean/Controllers/UsersController.cs new file mode 100644 index 0000000..72155cc --- /dev/null +++ b/tests/fixtures/aspnet-clean/Controllers/UsersController.cs @@ -0,0 +1,8 @@ +public class UsersController : Controller +{ + public IActionResult Find(string name) + { + var users = db.Users.FromSqlInterpolated($"SELECT * FROM Users WHERE Name = {name}"); + return Ok(users); + } +} diff --git a/tests/fixtures/aspnet-clean/LICENSE b/tests/fixtures/aspnet-clean/LICENSE new file mode 100644 index 0000000..33c61e4 --- /dev/null +++ b/tests/fixtures/aspnet-clean/LICENSE @@ -0,0 +1,5 @@ +MIT License + +Copyright (c) 2026 Example + +Permission is hereby granted, free of charge, to any person. diff --git a/tests/fixtures/aspnet-clean/Orders.Tests/UsersTests.cs b/tests/fixtures/aspnet-clean/Orders.Tests/UsersTests.cs new file mode 100644 index 0000000..bfa2693 --- /dev/null +++ b/tests/fixtures/aspnet-clean/Orders.Tests/UsersTests.cs @@ -0,0 +1 @@ +public class UsersTests {} diff --git a/tests/fixtures/aspnet-clean/Orders.csproj b/tests/fixtures/aspnet-clean/Orders.csproj new file mode 100644 index 0000000..f577589 --- /dev/null +++ b/tests/fixtures/aspnet-clean/Orders.csproj @@ -0,0 +1,5 @@ + + diff --git a/tests/fixtures/aspnet-clean/Program.cs b/tests/fixtures/aspnet-clean/Program.cs new file mode 100644 index 0000000..4185817 --- /dev/null +++ b/tests/fixtures/aspnet-clean/Program.cs @@ -0,0 +1,11 @@ +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddCors(o => o.AddPolicy("web", p => p.WithOrigins("https://example.com"))); +var app = builder.Build(); +if (app.Environment.IsDevelopment()) +{ + app.UseDeveloperExceptionPage(); + app.UseSwagger(); +} +app.UseCors("web"); +app.MapControllers(); +app.Run(); diff --git a/tests/fixtures/aspnet-clean/README.md b/tests/fixtures/aspnet-clean/README.md new file mode 100644 index 0000000..e571f42 --- /dev/null +++ b/tests/fixtures/aspnet-clean/README.md @@ -0,0 +1,5 @@ +# Orders + +A small example project used to exercise DevShield. It shows what a well-kept ASP.NET Core repository +looks like: documented setup, automated tests, a pinned CI pipeline, a changelog, a license and +contribution notes. Run the tests with the commands in CONTRIBUTING.md. diff --git a/tests/fixtures/aspnet-clean/appsettings.json b/tests/fixtures/aspnet-clean/appsettings.json new file mode 100644 index 0000000..857ae6b --- /dev/null +++ b/tests/fixtures/aspnet-clean/appsettings.json @@ -0,0 +1,10 @@ +{ + "ConnectionStrings": { + "Default": "Server=db;Database=orders;Integrated Security=true;" + }, + "Logging": { + "LogLevel": { + "Default": "Information" + } + } +} diff --git a/tests/fixtures/aspnet-insecure/Controllers/UsersController.cs b/tests/fixtures/aspnet-insecure/Controllers/UsersController.cs new file mode 100644 index 0000000..f593092 --- /dev/null +++ b/tests/fixtures/aspnet-insecure/Controllers/UsersController.cs @@ -0,0 +1,9 @@ +[IgnoreAntiforgeryToken] +public class UsersController : Controller +{ + public IActionResult Find(string name) + { + var users = db.Users.FromSqlRaw($"SELECT * FROM Users WHERE Name = '{name}'"); + return Ok(users); + } +} diff --git a/tests/fixtures/aspnet-insecure/Orders.csproj b/tests/fixtures/aspnet-insecure/Orders.csproj new file mode 100644 index 0000000..f577589 --- /dev/null +++ b/tests/fixtures/aspnet-insecure/Orders.csproj @@ -0,0 +1,5 @@ ++ +net8.0 ++ diff --git a/tests/fixtures/aspnet-insecure/Program.cs b/tests/fixtures/aspnet-insecure/Program.cs new file mode 100644 index 0000000..4a87ec5 --- /dev/null +++ b/tests/fixtures/aspnet-insecure/Program.cs @@ -0,0 +1,10 @@ +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddCors(o => o.AddPolicy("all", p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader())); +builder.Services.Configure+ +net8.0 +(o => o.Secure = CookieSecurePolicy.None); +var app = builder.Build(); +app.UseDeveloperExceptionPage(); +app.UseSwagger(); +app.UseSwaggerUI(); +app.UseCors("all"); +app.MapControllers(); +app.Run(); diff --git a/tests/fixtures/aspnet-insecure/appsettings.json b/tests/fixtures/aspnet-insecure/appsettings.json new file mode 100644 index 0000000..e608e11 --- /dev/null +++ b/tests/fixtures/aspnet-insecure/appsettings.json @@ -0,0 +1,9 @@ +{ + "ConnectionStrings": { + "Default": "Server=db;Database=orders;User Id=sa;Password=Sup3rSecret!2024;" + }, + "Jwt": { + "Key": "0123456789abcdef0123456789abcdef" + }, + "AllowedHosts": "*" +} diff --git a/tests/fixtures/cloudformation-clean/LICENSE b/tests/fixtures/cloudformation-clean/LICENSE new file mode 100644 index 0000000..33c61e4 --- /dev/null +++ b/tests/fixtures/cloudformation-clean/LICENSE @@ -0,0 +1,5 @@ +MIT License + +Copyright (c) 2026 Example + +Permission is hereby granted, free of charge, to any person. diff --git a/tests/fixtures/cloudformation-clean/README.md b/tests/fixtures/cloudformation-clean/README.md new file mode 100644 index 0000000..93c85ad --- /dev/null +++ b/tests/fixtures/cloudformation-clean/README.md @@ -0,0 +1,5 @@ +# Infrastructure + +A small example project used to exercise DevShield. It shows what a well-kept CloudFormation repository +looks like: documented setup, automated tests, a pinned CI pipeline, a changelog, a license and +contribution notes. Run the tests with the commands in CONTRIBUTING.md. diff --git a/tests/fixtures/cloudformation-clean/template.yaml b/tests/fixtures/cloudformation-clean/template.yaml new file mode 100644 index 0000000..918c7bd --- /dev/null +++ b/tests/fixtures/cloudformation-clean/template.yaml @@ -0,0 +1,40 @@ +AWSTemplateFormatVersion: '2010-09-09' +Parameters: + Env: + Type: String +Resources: + Assets: + Type: AWS::S3::Bucket + Properties: + BucketName: !Sub 'example-assets-${Env}' + AccessControl: Private + PublicAccessBlockConfiguration: + BlockPublicAcls: true + BlockPublicPolicy: true + IgnorePublicAcls: true + RestrictPublicBuckets: true + ReadPolicy: + Type: AWS::IAM::ManagedPolicy + Properties: + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: s3:GetObject + Resource: !Sub '${Assets.Arn}/*' + WebGroup: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: web + SecurityGroupIngress: + - IpProtocol: tcp + FromPort: 443 + ToPort: 443 + CidrIp: 0.0.0.0/0 + Database: + Type: AWS::RDS::DBInstance + Properties: + Engine: postgres + DBInstanceClass: db.t3.micro + StorageEncrypted: true + PubliclyAccessible: false diff --git a/tests/fixtures/cloudformation-insecure/template.yaml b/tests/fixtures/cloudformation-insecure/template.yaml new file mode 100644 index 0000000..9d7ba2f --- /dev/null +++ b/tests/fixtures/cloudformation-insecure/template.yaml @@ -0,0 +1,30 @@ +AWSTemplateFormatVersion: '2010-09-09' +Resources: + Assets: + Type: AWS::S3::Bucket + Properties: + AccessControl: PublicRead + AdminPolicy: + Type: AWS::IAM::ManagedPolicy + Properties: + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: '*' + Resource: '*' + WebGroup: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: web + SecurityGroupIngress: + - IpProtocol: tcp + FromPort: 22 + ToPort: 22 + CidrIp: 0.0.0.0/0 + Database: + Type: AWS::RDS::DBInstance + Properties: + Engine: postgres + DBInstanceClass: db.t3.micro + PubliclyAccessible: true diff --git a/tests/fixtures/django-clean/.github/workflows/ci.yml b/tests/fixtures/django-clean/.github/workflows/ci.yml new file mode 100644 index 0000000..d7e3b4e --- /dev/null +++ b/tests/fixtures/django-clean/.github/workflows/ci.yml @@ -0,0 +1,11 @@ +name: CI +on: pull_request +permissions: + contents: read +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 + - uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 + - run: pip install -r requirements.txt && pytest diff --git a/tests/fixtures/django-clean/.gitignore b/tests/fixtures/django-clean/.gitignore new file mode 100644 index 0000000..bc4f72a --- /dev/null +++ b/tests/fixtures/django-clean/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +.venv/ +.env diff --git a/tests/fixtures/django-clean/CHANGELOG.md b/tests/fixtures/django-clean/CHANGELOG.md new file mode 100644 index 0000000..58f70e6 --- /dev/null +++ b/tests/fixtures/django-clean/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## [Unreleased] + +- Initial version. diff --git a/tests/fixtures/django-clean/CONTRIBUTING.md b/tests/fixtures/django-clean/CONTRIBUTING.md new file mode 100644 index 0000000..664326f --- /dev/null +++ b/tests/fixtures/django-clean/CONTRIBUTING.md @@ -0,0 +1,3 @@ +# Contributing + +Open an issue first, keep changes small, and run the tests before sending a pull request. diff --git a/tests/fixtures/django-clean/LICENSE b/tests/fixtures/django-clean/LICENSE new file mode 100644 index 0000000..33c61e4 --- /dev/null +++ b/tests/fixtures/django-clean/LICENSE @@ -0,0 +1,5 @@ +MIT License + +Copyright (c) 2026 Example + +Permission is hereby granted, free of charge, to any person. diff --git a/tests/fixtures/django-clean/README.md b/tests/fixtures/django-clean/README.md new file mode 100644 index 0000000..c38a410 --- /dev/null +++ b/tests/fixtures/django-clean/README.md @@ -0,0 +1,5 @@ +# Shop + +A small example project used to exercise DevShield. It shows what a well-kept Django repository +looks like: documented setup, automated tests, a pinned CI pipeline, a changelog, a license and +contribution notes. Run the tests with the commands in CONTRIBUTING.md. diff --git a/tests/fixtures/django-clean/manage.py b/tests/fixtures/django-clean/manage.py new file mode 100644 index 0000000..ba16b2b --- /dev/null +++ b/tests/fixtures/django-clean/manage.py @@ -0,0 +1,3 @@ +import os + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings") diff --git a/tests/fixtures/django-clean/mysite/settings.py b/tests/fixtures/django-clean/mysite/settings.py new file mode 100644 index 0000000..b697af6 --- /dev/null +++ b/tests/fixtures/django-clean/mysite/settings.py @@ -0,0 +1,39 @@ +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent + +SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] +DEBUG = False +ALLOWED_HOSTS = os.environ["DJANGO_ALLOWED_HOSTS"].split(",") + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "shop", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", +] + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.environ["DB_NAME"], + "USER": os.environ["DB_USER"], + "PASSWORD": os.environ["DB_PASSWORD"], + "HOST": os.environ["DB_HOST"], + } +} + +SECURE_SSL_REDIRECT = True +SECURE_HSTS_SECONDS = 31536000 +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True +SESSION_COOKIE_HTTPONLY = True diff --git a/tests/fixtures/django-clean/requirements.txt b/tests/fixtures/django-clean/requirements.txt new file mode 100644 index 0000000..a443f69 --- /dev/null +++ b/tests/fixtures/django-clean/requirements.txt @@ -0,0 +1,2 @@ +Django==5.0.6 +gunicorn==22.0.0 diff --git a/tests/fixtures/django-clean/shop/views.py b/tests/fixtures/django-clean/shop/views.py new file mode 100644 index 0000000..cbf97e5 --- /dev/null +++ b/tests/fixtures/django-clean/shop/views.py @@ -0,0 +1,8 @@ +from django.db import connection +from django.http import HttpResponse + + +def search(request): + with connection.cursor() as cursor: + cursor.execute("SELECT * FROM shop_user WHERE name = %s", [request.GET["name"]]) + return HttpResponse("ok") diff --git a/tests/fixtures/django-clean/tests/test_views.py b/tests/fixtures/django-clean/tests/test_views.py new file mode 100644 index 0000000..a0e19ca --- /dev/null +++ b/tests/fixtures/django-clean/tests/test_views.py @@ -0,0 +1,2 @@ +def test_search(): + assert True diff --git a/tests/fixtures/django-insecure/manage.py b/tests/fixtures/django-insecure/manage.py new file mode 100644 index 0000000..ba16b2b --- /dev/null +++ b/tests/fixtures/django-insecure/manage.py @@ -0,0 +1,3 @@ +import os + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings") diff --git a/tests/fixtures/django-insecure/mysite/settings.py b/tests/fixtures/django-insecure/mysite/settings.py new file mode 100644 index 0000000..54c7435 --- /dev/null +++ b/tests/fixtures/django-insecure/mysite/settings.py @@ -0,0 +1,34 @@ +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent + +SECRET_KEY = "django-insecure-8x!j4k2lm9q0w3e5r7t1y6u8i0o2p4a6s8d" +DEBUG = True +ALLOWED_HOSTS = ["*"] + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "shop", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", +] + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": "shop", + "USER": "shop", + "PASSWORD": "shop-db-pass-2024", + "HOST": "db", + } +} + +SESSION_COOKIE_SECURE = False +CORS_ALLOW_ALL_ORIGINS = True diff --git a/tests/fixtures/django-insecure/requirements.txt b/tests/fixtures/django-insecure/requirements.txt new file mode 100644 index 0000000..c4acfdf --- /dev/null +++ b/tests/fixtures/django-insecure/requirements.txt @@ -0,0 +1,2 @@ +Django==5.0.6 +psycopg2-binary==2.9.9 diff --git a/tests/fixtures/django-insecure/shop/views.py b/tests/fixtures/django-insecure/shop/views.py new file mode 100644 index 0000000..4ab0a27 --- /dev/null +++ b/tests/fixtures/django-insecure/shop/views.py @@ -0,0 +1,30 @@ +import pickle +import subprocess + +import yaml +from django.db import connection +from django.http import HttpResponse +from django.views.decorators.csrf import csrf_exempt + +from .models import User + + +@csrf_exempt +def webhook(request): + payload = pickle.loads(request.body) + return HttpResponse(str(payload)) + + +def search(request): + name = request.GET["name"] + with connection.cursor() as cursor: + cursor.execute(f"SELECT * FROM shop_user WHERE name = '{name}'") + users = User.objects.raw("SELECT * FROM shop_user WHERE name = '%s'" % name) + return HttpResponse(len(list(users))) + + +def run(request): + result = eval(request.GET["expr"]) + subprocess.run(f"convert {request.GET['file']} out.png", shell=True) + config = yaml.load(request.body) + return HttpResponse(f"{result} {config}") diff --git a/tests/fixtures/fastapi-insecure/main.py b/tests/fixtures/fastapi-insecure/main.py new file mode 100644 index 0000000..61c86fd --- /dev/null +++ b/tests/fixtures/fastapi-insecure/main.py @@ -0,0 +1,19 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy import text + +SECRET_KEY = "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7" + +app = FastAPI(title="Orders", debug=True) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], +) + + +@app.get("/orders") +def orders(customer: str, db): + return db.execute(text(f"SELECT * FROM orders WHERE customer = '{customer}'")).all() diff --git a/tests/fixtures/fastapi-insecure/requirements.txt b/tests/fixtures/fastapi-insecure/requirements.txt new file mode 100644 index 0000000..bdb34c5 --- /dev/null +++ b/tests/fixtures/fastapi-insecure/requirements.txt @@ -0,0 +1,3 @@ +fastapi==0.110.0 +uvicorn==0.29.0 +sqlalchemy==2.0.29 diff --git a/tests/fixtures/flask-insecure/app.py b/tests/fixtures/flask-insecure/app.py new file mode 100644 index 0000000..712ddcb --- /dev/null +++ b/tests/fixtures/flask-insecure/app.py @@ -0,0 +1,21 @@ +import os + +from flask import Flask, request +from flask_cors import CORS + +app = Flask(__name__) +CORS(app) +app.secret_key = "dev" +app.config["SESSION_COOKIE_SECURE"] = False +app.config["SESSION_COOKIE_HTTPONLY"] = False +app.config["WTF_CSRF_ENABLED"] = False + + +@app.route("/run") +def run(): + os.system(f"ping {request.args['host']}") + return "ok" + + +if __name__ == "__main__": + app.run(host="0.0.0.0", debug=True) diff --git a/tests/fixtures/flask-insecure/requirements.txt b/tests/fixtures/flask-insecure/requirements.txt new file mode 100644 index 0000000..878661d --- /dev/null +++ b/tests/fixtures/flask-insecure/requirements.txt @@ -0,0 +1,2 @@ +Flask==3.0.3 +Flask-Cors==4.0.0 diff --git a/tests/fixtures/nestjs-insecure/package-lock.json b/tests/fixtures/nestjs-insecure/package-lock.json new file mode 100644 index 0000000..8de657e --- /dev/null +++ b/tests/fixtures/nestjs-insecure/package-lock.json @@ -0,0 +1 @@ +{"name": "orders-api", "lockfileVersion": 3, "packages": {"": {"name": "orders-api"}}} diff --git a/tests/fixtures/nestjs-insecure/package.json b/tests/fixtures/nestjs-insecure/package.json new file mode 100644 index 0000000..e969a4f --- /dev/null +++ b/tests/fixtures/nestjs-insecure/package.json @@ -0,0 +1,10 @@ +{ + "name": "orders-api", + "version": "1.0.0", + "private": true, + "dependencies": { + "@nestjs/common": "10.3.0", + "@nestjs/core": "10.3.0", + "@nestjs/platform-express": "10.3.0" + } +} diff --git a/tests/fixtures/nestjs-insecure/src/main.ts b/tests/fixtures/nestjs-insecure/src/main.ts new file mode 100644 index 0000000..da5451c --- /dev/null +++ b/tests/fixtures/nestjs-insecure/src/main.ts @@ -0,0 +1,9 @@ +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + app.enableCors(); + await app.listen(3000); +} +bootstrap(); diff --git a/tests/fixtures/spring-clean/.github/workflows/ci.yml b/tests/fixtures/spring-clean/.github/workflows/ci.yml new file mode 100644 index 0000000..30400ec --- /dev/null +++ b/tests/fixtures/spring-clean/.github/workflows/ci.yml @@ -0,0 +1,11 @@ +name: CI +on: pull_request +permissions: + contents: read +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 + - uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 + - run: mvn -B verify diff --git a/tests/fixtures/spring-clean/.gitignore b/tests/fixtures/spring-clean/.gitignore new file mode 100644 index 0000000..ad80088 --- /dev/null +++ b/tests/fixtures/spring-clean/.gitignore @@ -0,0 +1,3 @@ +target/ +.idea/ +*.class diff --git a/tests/fixtures/spring-clean/CHANGELOG.md b/tests/fixtures/spring-clean/CHANGELOG.md new file mode 100644 index 0000000..58f70e6 --- /dev/null +++ b/tests/fixtures/spring-clean/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## [Unreleased] + +- Initial version. diff --git a/tests/fixtures/spring-clean/CONTRIBUTING.md b/tests/fixtures/spring-clean/CONTRIBUTING.md new file mode 100644 index 0000000..664326f --- /dev/null +++ b/tests/fixtures/spring-clean/CONTRIBUTING.md @@ -0,0 +1,3 @@ +# Contributing + +Open an issue first, keep changes small, and run the tests before sending a pull request. diff --git a/tests/fixtures/spring-clean/LICENSE b/tests/fixtures/spring-clean/LICENSE new file mode 100644 index 0000000..33c61e4 --- /dev/null +++ b/tests/fixtures/spring-clean/LICENSE @@ -0,0 +1,5 @@ +MIT License + +Copyright (c) 2026 Example + +Permission is hereby granted, free of charge, to any person. diff --git a/tests/fixtures/spring-clean/README.md b/tests/fixtures/spring-clean/README.md new file mode 100644 index 0000000..3e6ea56 --- /dev/null +++ b/tests/fixtures/spring-clean/README.md @@ -0,0 +1,5 @@ +# Orders + +A small example project used to exercise DevShield. It shows what a well-kept Spring Boot repository +looks like: documented setup, automated tests, a pinned CI pipeline, a changelog, a license and +contribution notes. Run the tests with the commands in CONTRIBUTING.md. diff --git a/tests/fixtures/spring-clean/pom.xml b/tests/fixtures/spring-clean/pom.xml new file mode 100644 index 0000000..63ecc83 --- /dev/null +++ b/tests/fixtures/spring-clean/pom.xml @@ -0,0 +1,8 @@ + + diff --git a/tests/fixtures/spring-clean/src/main/java/com/example/SecurityConfig.java b/tests/fixtures/spring-clean/src/main/java/com/example/SecurityConfig.java new file mode 100644 index 0000000..70fe33d --- /dev/null +++ b/tests/fixtures/spring-clean/src/main/java/com/example/SecurityConfig.java @@ -0,0 +1,10 @@ +package com.example; + +public class SecurityConfig { + SecurityFilterChain chain(HttpSecurity http) throws Exception { + http.authorizeHttpRequests(a -> a + .requestMatchers("/health").permitAll() + .anyRequest().authenticated()); + return http.build(); + } +} diff --git a/tests/fixtures/spring-clean/src/main/java/com/example/UserRepository.java b/tests/fixtures/spring-clean/src/main/java/com/example/UserRepository.java new file mode 100644 index 0000000..c609d25 --- /dev/null +++ b/tests/fixtures/spring-clean/src/main/java/com/example/UserRepository.java @@ -0,0 +1,7 @@ +package com.example; + +public class UserRepository { + public Object find(String name) { + return em.createQuery("from User where name = :name").setParameter("name", name).getResultList(); + } +} diff --git a/tests/fixtures/spring-clean/src/main/resources/application.properties b/tests/fixtures/spring-clean/src/main/resources/application.properties new file mode 100644 index 0000000..3f399f8 --- /dev/null +++ b/tests/fixtures/spring-clean/src/main/resources/application.properties @@ -0,0 +1,7 @@ +spring.datasource.url=jdbc:postgresql://db:5432/orders +spring.datasource.username=${DB_USER} +spring.datasource.password=${DB_PASSWORD} +spring.jpa.hibernate.ddl-auto=validate +management.endpoints.web.exposure.include=health,info +server.servlet.session.cookie.secure=true +server.servlet.session.cookie.http-only=true diff --git a/tests/fixtures/spring-clean/src/test/java/com/example/UserRepositoryTest.java b/tests/fixtures/spring-clean/src/test/java/com/example/UserRepositoryTest.java new file mode 100644 index 0000000..3903fe4 --- /dev/null +++ b/tests/fixtures/spring-clean/src/test/java/com/example/UserRepositoryTest.java @@ -0,0 +1,3 @@ +package com.example; + +class UserRepositoryTest {} diff --git a/tests/fixtures/spring-insecure/pom.xml b/tests/fixtures/spring-insecure/pom.xml new file mode 100644 index 0000000..63ecc83 --- /dev/null +++ b/tests/fixtures/spring-insecure/pom.xml @@ -0,0 +1,8 @@ ++ +org.springframework.boot +spring-boot-starter-parent +3.2.5 +orders ++ diff --git a/tests/fixtures/spring-insecure/src/main/java/com/example/SecurityConfig.java b/tests/fixtures/spring-insecure/src/main/java/com/example/SecurityConfig.java new file mode 100644 index 0000000..d555e1f --- /dev/null +++ b/tests/fixtures/spring-insecure/src/main/java/com/example/SecurityConfig.java @@ -0,0 +1,18 @@ +package com.example; + +import org.springframework.context.annotation.Bean; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.web.bind.annotation.CrossOrigin; + +@CrossOrigin(origins = "*") +public class SecurityConfig { + @Bean + SecurityFilterChain chain(HttpSecurity http) throws Exception { + http + .csrf().disable() + .authorizeHttpRequests(a -> a + .anyRequest() + .permitAll()); + return http.build(); + } +} diff --git a/tests/fixtures/spring-insecure/src/main/java/com/example/UserRepository.java b/tests/fixtures/spring-insecure/src/main/java/com/example/UserRepository.java new file mode 100644 index 0000000..6d81b37 --- /dev/null +++ b/tests/fixtures/spring-insecure/src/main/java/com/example/UserRepository.java @@ -0,0 +1,7 @@ +package com.example; + +public class UserRepository { + public Object find(String name) { + return em.createQuery("from User where name = '" + name + "'").getResultList(); + } +} diff --git a/tests/fixtures/spring-insecure/src/main/resources/application.properties b/tests/fixtures/spring-insecure/src/main/resources/application.properties new file mode 100644 index 0000000..ba17331 --- /dev/null +++ b/tests/fixtures/spring-insecure/src/main/resources/application.properties @@ -0,0 +1,9 @@ +spring.datasource.url=jdbc:postgresql://db:5432/orders +spring.datasource.username=orders +spring.datasource.password=orders-db-pass-2024 +spring.jpa.hibernate.ddl-auto=update +spring.h2.console.enabled=true +management.endpoints.web.exposure.include=* +management.endpoint.health.show-details=always +server.error.include-stacktrace=always +server.servlet.session.cookie.secure=false diff --git a/tests/fixtures/terraform-clean/.gitignore b/tests/fixtures/terraform-clean/.gitignore new file mode 100644 index 0000000..d044fac --- /dev/null +++ b/tests/fixtures/terraform-clean/.gitignore @@ -0,0 +1,2 @@ +.terraform/ +*.tfstate diff --git a/tests/fixtures/terraform-clean/LICENSE b/tests/fixtures/terraform-clean/LICENSE new file mode 100644 index 0000000..33c61e4 --- /dev/null +++ b/tests/fixtures/terraform-clean/LICENSE @@ -0,0 +1,5 @@ +MIT License + +Copyright (c) 2026 Example + +Permission is hereby granted, free of charge, to any person. diff --git a/tests/fixtures/terraform-clean/README.md b/tests/fixtures/terraform-clean/README.md new file mode 100644 index 0000000..fbb944a --- /dev/null +++ b/tests/fixtures/terraform-clean/README.md @@ -0,0 +1,5 @@ +# Infrastructure + +A small example project used to exercise DevShield. It shows what a well-kept Terraform repository +looks like: documented setup, automated tests, a pinned CI pipeline, a changelog, a license and +contribution notes. Run the tests with the commands in CONTRIBUTING.md. diff --git a/tests/fixtures/terraform-clean/main.tf b/tests/fixtures/terraform-clean/main.tf new file mode 100644 index 0000000..b51c35d --- /dev/null +++ b/tests/fixtures/terraform-clean/main.tf @@ -0,0 +1,48 @@ +provider "aws" { + region = "eu-west-1" +} + +resource "aws_s3_bucket" "assets" { + bucket = "example-assets" +} + +resource "aws_s3_bucket_public_access_block" "assets" { + bucket = aws_s3_bucket.assets.id + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +data "aws_iam_policy_document" "read_assets" { + statement { + effect = "Allow" + actions = ["s3:GetObject"] + resources = ["arn:aws:s3:::example-assets/*"] + } +} + +resource "aws_security_group" "web" { + name = "web" + + ingress { + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = ["10.0.0.0/8"] + } +} + +resource "aws_db_instance" "main" { + engine = "postgres" + instance_class = "db.t3.micro" + storage_encrypted = true + publicly_accessible = false +} diff --git a/tests/fixtures/terraform-insecure/main.tf b/tests/fixtures/terraform-insecure/main.tf new file mode 100644 index 0000000..6d92ef4 --- /dev/null +++ b/tests/fixtures/terraform-insecure/main.tf @@ -0,0 +1,37 @@ +provider "aws" { + region = "eu-west-1" +} + +resource "aws_s3_bucket" "assets" { + bucket = "example-assets" + acl = "public-read" +} + +resource "aws_iam_policy" "admin" { + name = "admin" + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "*" + Resource = "*" + }] + }) +} + +resource "aws_security_group" "web" { + name = "web" + + ingress { + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } +} + +resource "aws_db_instance" "main" { + engine = "postgres" + instance_class = "db.t3.micro" + publicly_accessible = true +} diff --git a/tests/rules/cases_frameworks.py b/tests/rules/cases_frameworks.py new file mode 100644 index 0000000..84f8b74 --- /dev/null +++ b/tests/rules/cases_frameworks.py @@ -0,0 +1,644 @@ +from __future__ import annotations + +from tests.rules.helpers import ( + ASPNET, + DJANGO, + FASTAPI, + FLASK, + NEST, + SPRING, + Negative, + Positive, +) + +CFN_HEAD = "AWSTemplateFormatVersion: '2010-09-09'\nResources:\n" +CFN_BUCKET = CFN_HEAD + " Data:\n Type: AWS::S3::Bucket\n Properties:\n" + +POSITIVE: Positive = { + "SH001": [ + ({**DJANGO, "web/settings.py": "import os\nDEBUG = True\n"}, {"web/settings.py:2"}), + ( + {**DJANGO, "web/settings/production.py": "DEBUG = True\n"}, + {"web/settings/production.py:1"}, + ), + ({**FLASK, "app.py": "app.run(host='0.0.0.0', debug=True)\n"}, {"app.py:1"}), + ({**FASTAPI, "main.py": "app = FastAPI(debug=True)\n"}, {"main.py:1"}), + ({**FLASK, ".env.production": "FLASK_DEBUG=1\n"}, {".env.production:1"}), + ({**DJANGO, ".env.production": "DEBUG=True\n"}, {".env.production:1"}), + ( + { + **ASPNET, + "Program.cs": "var app = builder.Build();\napp.UseDeveloperExceptionPage();\n", + }, + {"Program.cs:2"}, + ), + ( + { + "Dockerfile": "FROM mcr.microsoft.com/dotnet/aspnet:8.0\nENV ASPNETCORE_ENVIRONMENT=Development\n" + }, + {"Dockerfile:2"}, + ), + ], + "SH002": [ + ( + { + "package.json": NEST, + "src/main.ts": "const app = await NestFactory.create(AppModule);\napp.enableCors();\n", + }, + {"src/main.ts:2"}, + ), + ( + {"package.json": NEST, "src/main.ts": "app.enableCors({ origin: true });\n"}, + {"src/main.ts:1"}, + ), + ], + "SH003": [ + ({"package.json": NEST, "src/main.ts": "const app = 1;\n"}, {"package.json:1"}), + ], + "SH014": [ + ( + {**DJANGO, "web/settings.py": "ALLOWED_HOSTS = ['*']\n"}, + {"web/settings.py:1"}, + ), + ( + {**DJANGO, "web/settings/base.py": "X = 1\nALLOWED_HOSTS = ['example.com', '*']\n"}, + {"web/settings/base.py:2"}, + ), + ], + "SH015": [ + ( + { + **DJANGO, + "web/settings.py": "DEBUG = False\nINSTALLED_APPS = []\nSECRET_KEY = os.environ['K']\n", + }, + {"web/settings.py:2"}, + ), + ( + { + **DJANGO, + "web/settings.py": "DEBUG = False\nINSTALLED_APPS = []\nSECURE_SSL_REDIRECT = True\n", + }, + {"web/settings.py:2"}, + ), + ], + "SH016": [ + ( + { + **FASTAPI, + "main.py": "app.add_middleware(CORSMiddleware, allow_origins=['*'], allow_credentials=True)\n", + }, + {"main.py:1"}, + ), + ( + {**FLASK, "app.py": "from flask_cors import CORS\nCORS(app)\n"}, + {"app.py:2"}, + ), + ({**DJANGO, "web/settings.py": "CORS_ALLOW_ALL_ORIGINS = True\n"}, {"web/settings.py:1"}), + ( + {**SPRING, "src/main/java/A.java": '@CrossOrigin(origins = "*")\npublic class A {}\n'}, + {"src/main/java/A.java:1"}, + ), + ( + {**SPRING, "src/main/java/C.java": 'registry.addMapping("/**").allowedOrigins("*");\n'}, + {"src/main/java/C.java:1"}, + ), + ( + {**ASPNET, "Program.cs": "policy.AllowAnyOrigin().AllowAnyMethod();\n"}, + {"Program.cs:1"}, + ), + ( + { + **SPRING, + "src/main/resources/application.properties": "spring.mvc.cors.allowed-origins=*\n", + }, + {"src/main/resources/application.properties:1"}, + ), + ], + "SH017": [ + ( + {**FASTAPI, "main.py": "from fastapi import FastAPI\napp = FastAPI(title='x')\n"}, + {"main.py:2"}, + ), + ({**ASPNET, "Program.cs": "app.UseSwagger();\napp.UseSwaggerUI();\n"}, {"Program.cs:1"}), + ], + "SH018": [ + ( + { + **SPRING, + "src/main/resources/application.properties": "management.endpoints.web.exposure.include=*\n", + }, + {"src/main/resources/application.properties:1"}, + ), + ( + { + **SPRING, + "src/main/resources/application.yml": ( + "management:\n endpoints:\n web:\n exposure:\n include: health,env\n" + ), + }, + {"src/main/resources/application.yml:5"}, + ), + ( + { + **SPRING, + "src/main/resources/application.properties": "spring.h2.console.enabled=true\nmanagement.endpoint.shutdown.enabled=true\n", + }, + { + "src/main/resources/application.properties:1", + "src/main/resources/application.properties:2", + }, + ), + ( + { + **SPRING, + "src/main/resources/application-prod.properties": "server.error.include-stacktrace=always\n", + }, + {"src/main/resources/application-prod.properties:1"}, + ), + ], + "SH019": [ + ( + { + **FLASK, + "app.py": "import os\nos.system(f'ls {path}')\neval(user_input)\nexec('x = 1')\n", + }, + {"app.py:2", "app.py:3"}, + ), + ( + {**DJANGO, "a.py": "import subprocess\nsubprocess.run(f'tar {name}', shell=True)\n"}, + {"a.py:2"}, + ), + ( + { + **FASTAPI, + "a.py": "import pickle\ndata = pickle.loads(blob)\nconf = yaml.load(text)\n", + }, + {"a.py:2", "a.py:3"}, + ), + ], + "SH020": [ + ( + { + **DJANGO, + "a.py": 'cursor.execute(f"SELECT * FROM t WHERE id = {pk}")\nUser.objects.raw("SELECT * FROM u WHERE n = \'%s\'" % name)\n', + }, + {"a.py:1", "a.py:2"}, + ), + ( + { + **FLASK, + "a.py": "db.execute('SELECT * FROM t WHERE a = ' + value)\nrows = db.execute(\"x {}\".format(v))\n", + }, + {"a.py:1", "a.py:2"}, + ), + ( + { + **SPRING, + "src/main/java/R.java": 'em.createQuery("from User where name = \'" + name + "\'");\n', + }, + {"src/main/java/R.java:1"}, + ), + ( + { + **ASPNET, + "Repo.cs": "db.Users.FromSqlRaw($\"SELECT * FROM Users WHERE Name = '{name}'\");\n", + }, + {"Repo.cs:1"}, + ), + ], + "SH106": [ + ( + { + **DJANGO, + "web/settings.py": "SESSION_COOKIE_SECURE = False\nCSRF_COOKIE_SECURE = True\n", + }, + {"web/settings.py:1"}, + ), + ( + {**DJANGO, "web/settings.py": "DEBUG = False\nINSTALLED_APPS = []\n"}, + {"web/settings.py:2"}, + ), + ({**FLASK, "app.py": "app.config['SESSION_COOKIE_HTTPONLY'] = False\n"}, {"app.py:1"}), + ( + { + **SPRING, + "src/main/resources/application.properties": "server.servlet.session.cookie.secure=false\n", + }, + {"src/main/resources/application.properties:1"}, + ), + ( + {**ASPNET, "Program.cs": "options.Cookie.SecurePolicy = CookieSecurePolicy.None;\n"}, + {"Program.cs:1"}, + ), + ], + "SH107": [ + ( + { + **DJANGO, + "web/settings.py": "MIDDLEWARE = ['django.middleware.security.SecurityMiddleware']\n", + }, + {"web/settings.py:1"}, + ), + ({**DJANGO, "views.py": "@csrf_exempt\ndef hook(request): pass\n"}, {"views.py:1"}), + ({**FLASK, "app.py": "app.config['WTF_CSRF_ENABLED'] = False\n"}, {"app.py:1"}), + ( + {**SPRING, "src/main/java/S.java": "http\n .csrf().disable()\n .build();\n"}, + {"src/main/java/S.java:2"}, + ), + ( + {**SPRING, "src/main/java/S.java": "http.csrf(AbstractHttpConfigurer::disable);\n"}, + {"src/main/java/S.java:1"}, + ), + ({**ASPNET, "C.cs": "[IgnoreAntiforgeryToken]\npublic class C {}\n"}, {"C.cs:1"}), + ], + "SH108": [ + ( + { + **SPRING, + "src/main/java/S.java": "http.authorizeHttpRequests(a -> a\n .anyRequest()\n .permitAll());\n", + }, + {"src/main/java/S.java:2"}, + ), + ( + {**SPRING, "src/main/java/S.java": 'auth.requestMatchers("/**").permitAll();\n'}, + {"src/main/java/S.java:1"}, + ), + ], + "SH109": [ + ({"package.json": NEST, "src/main.ts": "bootstrap();\n"}, {"src/main.ts"}), + ], + "SH206": [ + ( + {**DJANGO, "web/settings.py": "SECRET_KEY = 'django-insecure-abc'\n"}, + {"web/settings.py:1"}, + ), + ({**FLASK, "app.py": "app.secret_key = 'dev'\n"}, {"app.py:1"}), + ( + { + **FLASK, + "config.py": "class C:\n SECRET_KEY = os.environ.get('K') or 'fallback'\n", + }, + {"config.py:2"}, + ), + ({**FASTAPI, "auth.py": 'SECRET_KEY: str = "09d25e094faa"\n'}, {"auth.py:1"}), + ({**FLASK, "app.py": "app.config['SECRET_KEY'] = 'abc123'\n"}, {"app.py:1"}), + ], + "SH207": [ + ( + { + **SPRING, + "src/main/resources/application.properties": "spring.datasource.password=hunter2hunter2\n", + }, + {"src/main/resources/application.properties:1"}, + ), + ( + { + **SPRING, + "src/main/resources/application.yml": "spring:\n datasource:\n password: s3cr3tpass\n", + }, + {"src/main/resources/application.yml:3"}, + ), + ( + { + **ASPNET, + "appsettings.json": '{\n "ConnectionStrings": {\n "Default": "Server=db;User Id=sa;Password=Sup3rSecret!;"\n }\n}\n', + }, + {"appsettings.json:3"}, + ), + ( + { + **ASPNET, + "appsettings.json": '{\n "Jwt": {\n "Key": "0123456789abcdef0123456789abcdef"\n }\n}\n', + }, + {"appsettings.json:3"}, + ), + ], + "SH208": [ + ( + { + **DJANGO, + "web/settings.py": "DATABASES = {\n 'default': {\n 'PASSWORD': 'hunter2',\n }\n}\n", + }, + {"web/settings.py:3"}, + ), + ], + "SH303": [ + ( + { + **SPRING, + "src/main/resources/application.properties": "spring.jpa.hibernate.ddl-auto=update\n", + }, + {"src/main/resources/application.properties:1"}, + ), + ( + { + **SPRING, + "src/main/resources/application.yml": "spring:\n jpa:\n hibernate:\n ddl-auto: create-drop\n", + }, + {"src/main/resources/application.yml:4"}, + ), + ], + "SH401": [ + ({"main.tf": 'resource "aws_s3_bucket" "b" {\n acl = "public-read"\n}\n'}, {"main.tf:2"}), + ( + { + "main.tf": 'resource "aws_s3_bucket_public_access_block" "b" {\n block_public_acls = false\n block_public_policy = true\n}\n' + }, + {"main.tf:2"}, + ), + ( + { + "main.tf": 'resource "aws_s3_bucket_policy" "p" {\n policy = jsonencode({\n Statement = [{ Effect = "Allow", Principal = "*", Action = "s3:GetObject" }]\n })\n}\n' + }, + {"main.tf:2"}, + ), + ({"template.yaml": CFN_BUCKET + " AccessControl: PublicRead\n"}, {"template.yaml:6"}), + ( + { + "template.yaml": CFN_BUCKET + + " PublicAccessBlockConfiguration:\n BlockPublicAcls: false\n" + }, + {"template.yaml:7"}, + ), + ( + { + "template.yaml": ( + CFN_HEAD + + " P:\n Type: AWS::S3::BucketPolicy\n Properties:\n PolicyDocument:\n" + + " Statement:\n - Effect: Allow\n Principal: '*'\n Action: s3:GetObject\n" + ) + }, + {"template.yaml:6"}, + ), + ], + "SH402": [ + ( + { + "main.tf": 'resource "aws_iam_policy" "p" {\n policy = jsonencode({\n Statement = [{ Effect = "Allow", Action = "*", Resource = "*" }]\n })\n}\n' + }, + {"main.tf:2"}, + ), + ( + { + "main.tf": 'data "aws_iam_policy_document" "d" {\n statement {\n actions = ["*"]\n resources = ["arn:aws:s3:::x"]\n }\n}\n' + }, + {"main.tf:2"}, + ), + ( + { + "template.yaml": ( + CFN_HEAD + + " P:\n Type: AWS::IAM::Policy\n Properties:\n PolicyDocument:\n Statement:\n" + + " - Effect: Allow\n Action: '*'\n Resource: '*'\n" + ) + }, + {"template.yaml:3"}, + ), + ], + "SH403": [ + ( + { + "main.tf": 'resource "aws_security_group" "g" {\n ingress {\n from_port = 22\n to_port = 22\n protocol = "tcp"\n cidr_blocks = ["0.0.0.0/0"]\n }\n}\n' + }, + {"main.tf:2"}, + ), + ( + { + "main.tf": 'resource "aws_security_group_rule" "r" {\n type = "ingress"\n from_port = 5432\n to_port = 5432\n protocol = "tcp"\n cidr_blocks = ["0.0.0.0/0"]\n}\n' + }, + {"main.tf:1"}, + ), + ( + { + "template.yaml": ( + CFN_HEAD + + " G:\n Type: AWS::EC2::SecurityGroup\n Properties:\n SecurityGroupIngress:\n" + + " - IpProtocol: tcp\n FromPort: 3389\n ToPort: 3389\n CidrIp: 0.0.0.0/0\n" + ) + }, + {"template.yaml:3"}, + ), + ], + "SH404": [ + ({"main.tf": 'resource "aws_db_instance" "d" {\n engine = "mysql"\n}\n'}, {"main.tf:1"}), + ({"main.tf": 'resource "aws_ebs_volume" "v" {\n encrypted = false\n}\n'}, {"main.tf:2"}), + ( + { + "template.yaml": CFN_HEAD + + " D:\n Type: AWS::RDS::DBInstance\n Properties:\n Engine: mysql\n" + }, + {"template.yaml:3"}, + ), + ], + "SH405": [ + ( + {"main.tf": 'resource "aws_db_instance" "d" {\n publicly_accessible = true\n}\n'}, + {"main.tf:2"}, + ), + ( + { + "template.yaml": CFN_HEAD + + " D:\n Type: AWS::RDS::DBInstance\n Properties:\n PubliclyAccessible: true\n" + }, + {"template.yaml:6"}, + ), + ], +} + +NEGATIVE: Negative = { + "SH001": [ + {**DJANGO, "web/settings.py": "DEBUG = os.environ.get('DEBUG') == '1'\n"}, + {**DJANGO, "web/settings/dev.py": "DEBUG = True\n"}, + {**FLASK, "app.py": "app.run(host='0.0.0.0')\n"}, + {**FLASK, ".env.production": "FLASK_DEBUG=0\n"}, + {"package.json": '{"name": "x"}', ".env.production": "DEBUG=True\n"}, + { + **ASPNET, + "Program.cs": "if (app.Environment.IsDevelopment())\n{\n app.UseDeveloperExceptionPage();\n}\n", + }, + ], + "SH002": [ + { + "package.json": NEST, + "src/main.ts": "app.enableCors({ origin: 'https://example.com' });\n", + }, + ], + "SH003": [ + {"package.json": '{"dependencies": {"@nestjs/core": "10", "helmet": "7"}}'}, + ], + "SH014": [ + {**DJANGO, "web/settings.py": "ALLOWED_HOSTS = ['example.com']\n"}, + {**DJANGO, "web/settings/dev.py": "ALLOWED_HOSTS = ['*']\n"}, + {"web/settings.py": "ALLOWED_HOSTS = ['*']\n"}, + ], + "SH015": [ + { + **DJANGO, + "web/settings.py": "DEBUG = False\nINSTALLED_APPS = []\nSECURE_SSL_REDIRECT = True\nSECURE_HSTS_SECONDS = 31536000\n", + }, + {**DJANGO, "web/settings.py": "DEBUG = True\nINSTALLED_APPS = []\n"}, + ], + "SH016": [ + { + **FASTAPI, + "main.py": "app.add_middleware(CORSMiddleware, allow_origins=['https://example.com'])\n", + }, + { + **FLASK, + "app.py": "from flask_cors import CORS\nCORS(app, origins=['https://example.com'])\n", + }, + {**FLASK, "app.py": "CORS(app)\n"}, + { + **SPRING, + "src/main/java/C.java": 'registry.addMapping("/**").allowedOrigins("https://example.com");\n', + }, + {**ASPNET, "Program.cs": 'policy.WithOrigins("https://example.com");\n'}, + ], + "SH017": [ + {**FASTAPI, "main.py": "app = FastAPI(docs_url=None, redoc_url=None)\n"}, + {**FASTAPI, "main.py": "app = FastAPI(docs_url='/docs' if dev else None)\n"}, + { + **ASPNET, + "Program.cs": "if (app.Environment.IsDevelopment())\n{\n app.UseSwagger();\n}\n", + }, + ], + "SH018": [ + { + **SPRING, + "src/main/resources/application.properties": "management.endpoints.web.exposure.include=health,info\n", + }, + { + **SPRING, + "src/main/resources/application-dev.properties": "management.endpoints.web.exposure.include=*\n", + }, + { + **SPRING, + "src/main/resources/application.properties": "spring.h2.console.enabled=false\n", + }, + ], + "SH019": [ + { + **FLASK, + "app.py": "import os\nos.system('ls -la')\nvalue = ast.literal_eval(text)\nmodel.eval()\n", + }, + {**DJANGO, "a.py": "subprocess.run(['tar', name])\nsubprocess.run('ls', shell=True)\n"}, + { + **FASTAPI, + "a.py": "conf = yaml.load(text, Loader=yaml.SafeLoader)\nyaml.safe_load(text)\n", + }, + ], + "SH020": [ + { + **DJANGO, + "a.py": "cursor.execute('SELECT * FROM t WHERE id = %s', [pk])\nUser.objects.raw('SELECT 1')\n", + }, + {**FLASK, "a.py": "db.execute(text('SELECT * FROM t WHERE a = :a'), {'a': value})\n"}, + { + **SPRING, + "src/main/java/R.java": 'em.createQuery("from User where name = :n").setParameter("n", name);\n', + }, + { + **ASPNET, + "Repo.cs": 'db.Users.FromSqlInterpolated($"SELECT * FROM Users WHERE Name = {name}");\n', + }, + ], + "SH106": [ + {**DJANGO, "web/settings.py": "SESSION_COOKIE_SECURE = True\nCSRF_COOKIE_SECURE = True\n"}, + {**DJANGO, "web/settings.py": "DEBUG = True\nINSTALLED_APPS = []\n"}, + { + **SPRING, + "src/main/resources/application.properties": "server.servlet.session.cookie.secure=true\n", + }, + { + **SPRING, + "src/main/resources/application-dev.properties": "server.servlet.session.cookie.secure=false\n", + }, + ], + "SH107": [ + { + **DJANGO, + "web/settings.py": "MIDDLEWARE = ['django.middleware.csrf.CsrfViewMiddleware']\n", + }, + {**FLASK, "app.py": "app.config['WTF_CSRF_ENABLED'] = True\n"}, + {**SPRING, "src/main/java/S.java": "http.csrf(Customizer.withDefaults());\n"}, + ], + "SH108": [ + { + **SPRING, + "src/main/java/S.java": 'a.requestMatchers("/public/**").permitAll().anyRequest().authenticated();\n', + }, + ], + "SH109": [ + { + "package.json": NEST, + "src/main.ts": "app.useGlobalPipes(new ValidationPipe({ whitelist: true }));\n", + }, + ], + "SH206": [ + {**DJANGO, "web/settings.py": "SECRET_KEY = os.environ['SECRET_KEY']\n"}, + {**FLASK, "app.py": "app.secret_key = os.environ.get('SECRET_KEY')\n"}, + {**DJANGO, "web/settings/dev.py": "SECRET_KEY = 'dev-only'\n"}, + {**FLASK, "tests/test_app.py": "app.secret_key = 'test'\n"}, + ], + "SH207": [ + { + **SPRING, + "src/main/resources/application.properties": "spring.datasource.password=${DB_PASSWORD}\nspring.datasource.url=jdbc:h2:mem:x\njwt.expiration=3600\nsecurity.token.name=x\n", + }, + {**SPRING, "src/main/resources/application.properties": "spring.datasource.password=\n"}, + { + **ASPNET, + "appsettings.json": '{\n "Logging": {"LogLevel": {"Default": "Information"}},\n "ConnectionStrings": {"Default": "Server=db;Integrated Security=true;"}\n}\n', + }, + ], + "SH208": [ + { + **DJANGO, + "web/settings.py": "DATABASES = {'default': {'PASSWORD': os.environ['DB_PASSWORD']}}\n", + }, + ], + "SH303": [ + { + **SPRING, + "src/main/resources/application.properties": "spring.jpa.hibernate.ddl-auto=validate\n", + }, + { + **SPRING, + "src/main/resources/application-dev.properties": "spring.jpa.hibernate.ddl-auto=create\n", + }, + ], + "SH401": [ + { + "main.tf": 'resource "aws_s3_bucket" "b" {\n acl = "private"\n}\nresource "aws_s3_bucket_public_access_block" "x" {\n block_public_acls = true\n}\n' + }, + {"template.yaml": CFN_BUCKET + " AccessControl: Private\n"}, + { + "main.tf": 'resource "aws_s3_bucket_policy" "p" {\n policy = jsonencode({ Statement = [{ Effect = "Allow", Principal = { AWS = "arn:aws:iam::1:root" }, Action = "s3:GetObject" }] })\n}\n' + }, + ], + "SH402": [ + { + "main.tf": 'resource "aws_iam_policy" "p" {\n policy = jsonencode({ Statement = [{ Effect = "Allow", Action = ["s3:GetObject"], Resource = "arn:aws:s3:::x/*" }] })\n}\n' + }, + { + "main.tf": 'data "aws_iam_policy_document" "d" {\n statement {\n effect = "Deny"\n actions = ["*"]\n resources = ["*"]\n }\n}\n' + }, + ], + "SH403": [ + { + "main.tf": 'resource "aws_security_group" "g" {\n ingress {\n from_port = 443\n to_port = 443\n protocol = "tcp"\n cidr_blocks = ["0.0.0.0/0"]\n }\n ingress {\n from_port = 22\n to_port = 22\n protocol = "tcp"\n cidr_blocks = ["10.0.0.0/8"]\n }\n}\n' + }, + ], + "SH404": [ + { + "main.tf": 'resource "aws_db_instance" "d" {\n storage_encrypted = true\n}\nresource "aws_db_instance" "r" {\n replicate_source_db = "x"\n}\n' + }, + { + "template.yaml": CFN_HEAD + + " D:\n Type: AWS::RDS::DBInstance\n Properties:\n StorageEncrypted: true\n" + }, + ], + "SH405": [ + {"main.tf": 'resource "aws_db_instance" "d" {\n publicly_accessible = false\n}\n'}, + ], +} diff --git a/tests/rules/helpers.py b/tests/rules/helpers.py index 5e0a157..00fef6a 100644 --- a/tests/rules/helpers.py +++ b/tests/rules/helpers.py @@ -17,6 +17,14 @@ NEXT = '{"dependencies": {"next": "14"}}' LARAVEL = {"artisan": "", "composer.json": '{"require": {"laravel/framework": "^11"}}'} WORKFLOW = ".github/workflows/ci.yml" +DJANGO = {"requirements.txt": "Django==5.0\n"} +FASTAPI = {"requirements.txt": "fastapi==0.110\n"} +FLASK = {"requirements.txt": "Flask==3.0\n"} +NEST = '{"dependencies": {"@nestjs/core": "10"}}' +SPRING = {"pom.xml": "+ +org.springframework.boot +spring-boot-starter-parent +3.2.5 +orders +\n"} +ASPNET = {"Web.csproj": ' spring-boot-starter-parent \n'} +TERRAFORM = "main.tf" +CLOUDFORMATION = "template.yaml" AWS_KEY = "AKIA" + "Q3EGRXZ7K2JTHPLM" GITHUB_TOKEN = "ghp_" + "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8" GENERIC_SECRET = "q8Zr2LmP0v" + "Xw93TbYk6Nd" diff --git a/tests/rules/test_rules.py b/tests/rules/test_rules.py index 6ee0a16..fa50ac8 100644 --- a/tests/rules/test_rules.py +++ b/tests/rules/test_rules.py @@ -3,10 +3,10 @@ import pytest from tests.conftest import MakeProject -from tests.rules import cases_infra, cases_secrets, cases_web +from tests.rules import cases_frameworks, cases_infra, cases_secrets, cases_web from tests.rules.helpers import REGISTRY, Files, locations -MODULES = (cases_secrets, cases_web, cases_infra) +MODULES = (cases_secrets, cases_web, cases_infra, cases_frameworks) POSITIVE = [ pytest.param(rule_id, files, expected, id=f"{rule_id}-{i}") for module in MODULES diff --git a/tests/snapshots/aspnet-clean.json b/tests/snapshots/aspnet-clean.json new file mode 100644 index 0000000..a4cdcb6 --- /dev/null +++ b/tests/snapshots/aspnet-clean.json @@ -0,0 +1,60 @@ +{ + "schema_version": 1, + "frameworks": [ + { + "framework": "aspnet", + "confidence": "high", + "evidence": [ + "Orders.csproj" + ] + }, + { + "framework": "github_actions", + "confidence": "high", + "evidence": [ + ".github/workflows/ci.yml" + ] + } + ], + "findings": [], + "errors": [], + "warnings": [], + "rules_run": [ + "DOC001", + "DOC002", + "DOC003", + "DX001", + "DX002", + "DX003", + "DX004", + "DX005", + "DX006", + "DX007", + "DX008", + "DX009", + "DX010", + "DX011", + "PERF301", + "PERF302", + "PERF303", + "PERF304", + "SH001", + "SH013", + "SH016", + "SH017", + "SH020", + "SH106", + "SH107", + "SH201", + "SH202", + "SH203", + "SH205", + "SH207", + "SH301", + "SH302" + ], + "suppressed": 0, + "baselined": 0, + "score": 100, + "band": "low" +} diff --git a/tests/snapshots/aspnet-insecure.json b/tests/snapshots/aspnet-insecure.json new file mode 100644 index 0000000..4c0ad22 --- /dev/null +++ b/tests/snapshots/aspnet-insecure.json @@ -0,0 +1,210 @@ +{ + "schema_version": 1, + "frameworks": [ + { + "framework": "aspnet", + "confidence": "high", + "evidence": [ + "Orders.csproj" + ] + } + ], + "findings": [ + { + "rule_id": "SH020", + "title": "SQL query built from string formatting", + "severity": "high", + "category": "security", + "message": "Query is built from a formatted string", + "location": { + "file": "Controllers/UsersController.cs", + "line": 6, + "column": null + }, + "remediation": "Use parameterised queries: cursor.execute(sql, params), PreparedStatement with ?, or FromSqlInterpolated / SqlParameter in .NET.", + "fixable": false + }, + { + "rule_id": "SH001", + "title": "Debug mode enabled", + "severity": "high", + "category": "security", + "message": "The developer exception page is enabled in every environment", + "location": { + "file": "Program.cs", + "line": 5, + "column": null + }, + "remediation": "Set APP_DEBUG=false (and NODE_ENV=production) for every deployed environment.", + "fixable": false + }, + { + "rule_id": "SH207", + "title": "Credential written in an application config file", + "severity": "high", + "category": "secrets", + "message": "ConnectionStrings:Default contains a password", + "location": { + "file": "appsettings.json", + "line": 3, + "column": null + }, + "remediation": "Reference an environment variable (${DB_PASSWORD} in Spring, environment variables or user-secrets in .NET) or a secret manager, and rotate the exposed value.", + "fixable": false + }, + { + "rule_id": "SH207", + "title": "Credential written in an application config file", + "severity": "high", + "category": "secrets", + "message": "Jwt:Key holds a literal secret", + "location": { + "file": "appsettings.json", + "line": 6, + "column": null + }, + "remediation": "Reference an environment variable (${DB_PASSWORD} in Spring, environment variables or user-secrets in .NET) or a secret manager, and rotate the exposed value.", + "fixable": false + }, + { + "rule_id": "DOC001", + "title": "README missing or nearly empty", + "severity": "medium", + "category": "documentation", + "message": "No README found", + "location": null, + "remediation": "Describe what the project does, how to install, run and test it.", + "fixable": false + }, + { + "rule_id": "DX001", + "title": "No automated tests detected", + "severity": "medium", + "category": "developer_experience", + "message": "No test files or test directories found", + "location": null, + "remediation": "Add a test runner (Jest/Vitest, PHPUnit/Pest) and cover the critical paths.", + "fixable": false + }, + { + "rule_id": "SH107", + "title": "CSRF protection disabled", + "severity": "medium", + "category": "authentication", + "message": "Antiforgery validation is skipped", + "location": { + "file": "Controllers/UsersController.cs", + "line": 1, + "column": null + }, + "remediation": "Keep CSRF protection on for cookie-based sessions; disable it only for stateless token APIs, and scope the exemption to those endpoints.", + "fixable": false + }, + { + "rule_id": "SH016", + "title": "Permissive CORS in a Python, Java or .NET API", + "severity": "medium", + "category": "security", + "message": "CORS allows any origin", + "location": { + "file": "Program.cs", + "line": 2, + "column": null + }, + "remediation": "List the exact origins that need access instead of '*'.", + "fixable": false + }, + { + "rule_id": "SH106", + "title": "Session cookies not hardened", + "severity": "medium", + "category": "authentication", + "message": "Cookie is sent without the Secure or HttpOnly flag", + "location": { + "file": "Program.cs", + "line": 3, + "column": null + }, + "remediation": "Enable the Secure and HttpOnly flags for session and CSRF cookies.", + "fixable": false + }, + { + "rule_id": "DOC002", + "title": "No license", + "severity": "low", + "category": "documentation", + "message": "No LICENSE file found", + "location": null, + "remediation": "Add a LICENSE file (choosealicense.com can help you pick one).", + "fixable": false + }, + { + "rule_id": "DX002", + "title": "No CI workflow", + "severity": "low", + "category": "developer_experience", + "message": "No CI configuration found", + "location": null, + "remediation": "Add a pipeline (for example GitHub Actions) that runs tests and `devshield scan`.", + "fixable": true + }, + { + "rule_id": "DX008", + "title": ".gitignore missing or incomplete", + "severity": "low", + "category": "developer_experience", + "message": "No .gitignore in the project root", + "location": null, + "remediation": "Add a .gitignore for your stack that covers dependencies, build output and .env.", + "fixable": false + }, + { + "rule_id": "SH017", + "title": "API documentation exposed", + "severity": "low", + "category": "security", + "message": "Swagger is enabled in every environment", + "location": { + "file": "Program.cs", + "line": 6, + "column": null + }, + "remediation": "Disable it in production: FastAPI(docs_url=None, redoc_url=None, openapi_url=None), or call UseSwagger only inside an IsDevelopment() check.", + "fixable": false + } + ], + "errors": [], + "warnings": [], + "rules_run": [ + "DOC001", + "DOC002", + "DOC003", + "DX001", + "DX002", + "DX007", + "DX008", + "DX009", + "PERF301", + "PERF302", + "PERF303", + "PERF304", + "SH001", + "SH013", + "SH016", + "SH017", + "SH020", + "SH106", + "SH107", + "SH201", + "SH202", + "SH203", + "SH205", + "SH207", + "SH301", + "SH302" + ], + "suppressed": 0, + "baselined": 0, + "score": 15, + "band": "critical" +} diff --git a/tests/snapshots/cloudformation-clean.json b/tests/snapshots/cloudformation-clean.json new file mode 100644 index 0000000..a28f47e --- /dev/null +++ b/tests/snapshots/cloudformation-clean.json @@ -0,0 +1,40 @@ +{ + "schema_version": 1, + "frameworks": [ + { + "framework": "cloudformation", + "confidence": "high", + "evidence": [ + "template.yaml" + ] + } + ], + "findings": [], + "errors": [], + "warnings": [], + "rules_run": [ + "DX007", + "DX009", + "PERF301", + "PERF302", + "PERF303", + "PERF304", + "SH001", + "SH013", + "SH201", + "SH202", + "SH203", + "SH205", + "SH301", + "SH302", + "SH401", + "SH402", + "SH403", + "SH404", + "SH405" + ], + "suppressed": 0, + "baselined": 0, + "score": 100, + "band": "low" +} diff --git a/tests/snapshots/cloudformation-insecure.json b/tests/snapshots/cloudformation-insecure.json new file mode 100644 index 0000000..86a9afe --- /dev/null +++ b/tests/snapshots/cloudformation-insecure.json @@ -0,0 +1,111 @@ +{ + "schema_version": 1, + "frameworks": [ + { + "framework": "cloudformation", + "confidence": "high", + "evidence": [ + "template.yaml" + ] + } + ], + "findings": [ + { + "rule_id": "SH402", + "title": "IAM policy grants wildcard permissions", + "severity": "critical", + "category": "security", + "message": "Allows every action on every resource", + "location": { + "file": "template.yaml", + "line": 7, + "column": null + }, + "remediation": "List the specific actions and resources the workload needs (least privilege).", + "fixable": false + }, + { + "rule_id": "SH401", + "title": "S3 bucket is publicly accessible", + "severity": "high", + "category": "security", + "message": "Assets uses the PublicRead access control", + "location": { + "file": "template.yaml", + "line": 6, + "column": null + }, + "remediation": "Use a private ACL, enable all four settings of the public access block, and grant access with specific principals or CloudFront origin access control.", + "fixable": false + }, + { + "rule_id": "SH403", + "title": "Security group open to the internet", + "severity": "high", + "category": "security", + "message": "WebGroup allows the internet to reach port 22", + "location": { + "file": "template.yaml", + "line": 16, + "column": null + }, + "remediation": "Restrict the source to your own CIDR range or a security group, and use SSM Session Manager or a VPN instead of public SSH.", + "fixable": false + }, + { + "rule_id": "SH405", + "title": "Database reachable from the internet", + "severity": "high", + "category": "security", + "message": "Database has PubliclyAccessible: true", + "location": { + "file": "template.yaml", + "line": 30, + "column": null + }, + "remediation": "Set publicly_accessible = false and reach the database through a private subnet, a VPN or a bastion host.", + "fixable": false + }, + { + "rule_id": "SH404", + "title": "Storage encryption at rest not enabled", + "severity": "medium", + "category": "security", + "message": "Database does not set StorageEncrypted to true", + "location": { + "file": "template.yaml", + "line": 25, + "column": null + }, + "remediation": "Set storage_encrypted = true (RDS) or encrypted = true (EBS), ideally with a KMS key.", + "fixable": false + } + ], + "errors": [], + "warnings": [], + "rules_run": [ + "DX007", + "DX009", + "PERF301", + "PERF302", + "PERF303", + "PERF304", + "SH001", + "SH013", + "SH201", + "SH202", + "SH203", + "SH205", + "SH301", + "SH302", + "SH401", + "SH402", + "SH403", + "SH404", + "SH405" + ], + "suppressed": 0, + "baselined": 0, + "score": 27, + "band": "critical" +} diff --git a/tests/snapshots/django-clean.json b/tests/snapshots/django-clean.json new file mode 100644 index 0000000..2c7657f --- /dev/null +++ b/tests/snapshots/django-clean.json @@ -0,0 +1,64 @@ +{ + "schema_version": 1, + "frameworks": [ + { + "framework": "django", + "confidence": "high", + "evidence": [ + "Python dependency django", + "manage.py" + ] + }, + { + "framework": "github_actions", + "confidence": "high", + "evidence": [ + ".github/workflows/ci.yml" + ] + } + ], + "findings": [], + "errors": [], + "warnings": [], + "rules_run": [ + "DOC001", + "DOC002", + "DOC003", + "DX001", + "DX002", + "DX003", + "DX004", + "DX005", + "DX006", + "DX007", + "DX008", + "DX009", + "DX010", + "DX011", + "PERF301", + "PERF302", + "PERF303", + "PERF304", + "SH001", + "SH013", + "SH014", + "SH015", + "SH016", + "SH019", + "SH020", + "SH106", + "SH107", + "SH201", + "SH202", + "SH203", + "SH205", + "SH206", + "SH208", + "SH301", + "SH302" + ], + "suppressed": 0, + "baselined": 0, + "score": 100, + "band": "low" +} diff --git a/tests/snapshots/django-insecure.json b/tests/snapshots/django-insecure.json new file mode 100644 index 0000000..a50e7fa --- /dev/null +++ b/tests/snapshots/django-insecure.json @@ -0,0 +1,326 @@ +{ + "schema_version": 1, + "frameworks": [ + { + "framework": "django", + "confidence": "high", + "evidence": [ + "Python dependency django", + "manage.py" + ] + } + ], + "findings": [ + { + "rule_id": "SH201", + "title": "Hard-coded secret", + "severity": "high", + "category": "secrets", + "message": "Hard-coded secret found: djan******", + "location": { + "file": "mysite/settings.py", + "line": 5, + "column": null + }, + "remediation": "Revoke the credential, move it to an environment variable or secret manager, and remove it from git history.", + "fixable": false + }, + { + "rule_id": "SH206", + "title": "Hard-coded framework secret key", + "severity": "high", + "category": "secrets", + "message": "SECRET_KEY is a hard-coded string", + "location": { + "file": "mysite/settings.py", + "line": 5, + "column": null + }, + "remediation": "Load the key from an environment variable or a secret manager and fail to start when it is missing.", + "fixable": false + }, + { + "rule_id": "SH001", + "title": "Debug mode enabled", + "severity": "high", + "category": "security", + "message": "Django settings set DEBUG = True", + "location": { + "file": "mysite/settings.py", + "line": 6, + "column": null + }, + "remediation": "Set APP_DEBUG=false (and NODE_ENV=production) for every deployed environment.", + "fixable": false + }, + { + "rule_id": "SH201", + "title": "Hard-coded secret", + "severity": "high", + "category": "secrets", + "message": "Hard-coded secret found: shop******", + "location": { + "file": "mysite/settings.py", + "line": 28, + "column": null + }, + "remediation": "Revoke the credential, move it to an environment variable or secret manager, and remove it from git history.", + "fixable": false + }, + { + "rule_id": "SH208", + "title": "Database password written in Django settings", + "severity": "high", + "category": "secrets", + "message": "DATABASES contains a literal PASSWORD", + "location": { + "file": "mysite/settings.py", + "line": 28, + "column": null + }, + "remediation": "Read the password from an environment variable, for example os.environ['DB_PASSWORD'].", + "fixable": false + }, + { + "rule_id": "SH020", + "title": "SQL query built from string formatting", + "severity": "high", + "category": "security", + "message": "Query is built from a formatted string", + "location": { + "file": "shop/views.py", + "line": 22, + "column": null + }, + "remediation": "Use parameterised queries: cursor.execute(sql, params), PreparedStatement with ?, or FromSqlInterpolated / SqlParameter in .NET.", + "fixable": false + }, + { + "rule_id": "SH019", + "title": "Unsafe code execution or deserialization in Python", + "severity": "high", + "category": "security", + "message": "Code or command is built dynamically", + "location": { + "file": "shop/views.py", + "line": 27, + "column": null + }, + "remediation": "Avoid eval and exec. Pass subprocess an argument list without shell=True, use json instead of pickle, and yaml.safe_load instead of yaml.load.", + "fixable": false + }, + { + "rule_id": "SH019", + "title": "Unsafe code execution or deserialization in Python", + "severity": "high", + "category": "security", + "message": "Shell command is built dynamically", + "location": { + "file": "shop/views.py", + "line": 28, + "column": null + }, + "remediation": "Avoid eval and exec. Pass subprocess an argument list without shell=True, use json instead of pickle, and yaml.safe_load instead of yaml.load.", + "fixable": false + }, + { + "rule_id": "DOC001", + "title": "README missing or nearly empty", + "severity": "medium", + "category": "documentation", + "message": "No README found", + "location": null, + "remediation": "Describe what the project does, how to install, run and test it.", + "fixable": false + }, + { + "rule_id": "DX001", + "title": "No automated tests detected", + "severity": "medium", + "category": "developer_experience", + "message": "No test files or test directories found", + "location": null, + "remediation": "Add a test runner (Jest/Vitest, PHPUnit/Pest) and cover the critical paths.", + "fixable": false + }, + { + "rule_id": "SH014", + "title": "Django accepts any Host header", + "severity": "medium", + "category": "security", + "message": "ALLOWED_HOSTS contains '*'", + "location": { + "file": "mysite/settings.py", + "line": 7, + "column": null + }, + "remediation": "List the real host names in ALLOWED_HOSTS, for example ['example.com'].", + "fixable": false + }, + { + "rule_id": "SH107", + "title": "CSRF protection disabled", + "severity": "medium", + "category": "authentication", + "message": "CsrfViewMiddleware is not in MIDDLEWARE", + "location": { + "file": "mysite/settings.py", + "line": 17, + "column": null + }, + "remediation": "Keep CSRF protection on for cookie-based sessions; disable it only for stateless token APIs, and scope the exemption to those endpoints.", + "fixable": false + }, + { + "rule_id": "SH106", + "title": "Session cookies not hardened", + "severity": "medium", + "category": "authentication", + "message": "SESSION_COOKIE_SECURE = False", + "location": { + "file": "mysite/settings.py", + "line": 33, + "column": null + }, + "remediation": "Enable the Secure and HttpOnly flags for session and CSRF cookies.", + "fixable": false + }, + { + "rule_id": "SH106", + "title": "Session cookies not hardened", + "severity": "medium", + "category": "authentication", + "message": "SESSION_COOKIE_SECURE is False", + "location": { + "file": "mysite/settings.py", + "line": 33, + "column": null + }, + "remediation": "Enable the Secure and HttpOnly flags for session and CSRF cookies.", + "fixable": false + }, + { + "rule_id": "SH016", + "title": "Permissive CORS in a Python, Java or .NET API", + "severity": "medium", + "category": "security", + "message": "CORS allows any origin", + "location": { + "file": "mysite/settings.py", + "line": 34, + "column": null + }, + "remediation": "List the exact origins that need access instead of '*'.", + "fixable": false + }, + { + "rule_id": "SH019", + "title": "Unsafe code execution or deserialization in Python", + "severity": "medium", + "category": "security", + "message": "pickle.loads can run code from untrusted data", + "location": { + "file": "shop/views.py", + "line": 14, + "column": null + }, + "remediation": "Avoid eval and exec. Pass subprocess an argument list without shell=True, use json instead of pickle, and yaml.safe_load instead of yaml.load.", + "fixable": false + }, + { + "rule_id": "SH019", + "title": "Unsafe code execution or deserialization in Python", + "severity": "medium", + "category": "security", + "message": "yaml.load without a safe loader can run code", + "location": { + "file": "shop/views.py", + "line": 29, + "column": null + }, + "remediation": "Avoid eval and exec. Pass subprocess an argument list without shell=True, use json instead of pickle, and yaml.safe_load instead of yaml.load.", + "fixable": false + }, + { + "rule_id": "DOC002", + "title": "No license", + "severity": "low", + "category": "documentation", + "message": "No LICENSE file found", + "location": null, + "remediation": "Add a LICENSE file (choosealicense.com can help you pick one).", + "fixable": false + }, + { + "rule_id": "DX002", + "title": "No CI workflow", + "severity": "low", + "category": "developer_experience", + "message": "No CI configuration found", + "location": null, + "remediation": "Add a pipeline (for example GitHub Actions) that runs tests and `devshield scan`.", + "fixable": true + }, + { + "rule_id": "DX008", + "title": ".gitignore missing or incomplete", + "severity": "low", + "category": "developer_experience", + "message": "No .gitignore in the project root", + "location": null, + "remediation": "Add a .gitignore for your stack that covers dependencies, build output and .env.", + "fixable": false + }, + { + "rule_id": "SH107", + "title": "CSRF protection disabled", + "severity": "low", + "category": "authentication", + "message": "csrf_exempt turns CSRF protection off for this view", + "location": { + "file": "shop/views.py", + "line": 12, + "column": null + }, + "remediation": "Keep CSRF protection on for cookie-based sessions; disable it only for stateless token APIs, and scope the exemption to those endpoints.", + "fixable": false + } + ], + "errors": [], + "warnings": [], + "rules_run": [ + "DOC001", + "DOC002", + "DOC003", + "DX001", + "DX002", + "DX007", + "DX008", + "DX009", + "PERF301", + "PERF302", + "PERF303", + "PERF304", + "SH001", + "SH013", + "SH014", + "SH015", + "SH016", + "SH019", + "SH020", + "SH106", + "SH107", + "SH201", + "SH202", + "SH203", + "SH205", + "SH206", + "SH208", + "SH301", + "SH302" + ], + "suppressed": 0, + "baselined": 0, + "score": 4, + "band": "critical" +} diff --git a/tests/snapshots/fastapi-insecure.json b/tests/snapshots/fastapi-insecure.json new file mode 100644 index 0000000..50f0595 --- /dev/null +++ b/tests/snapshots/fastapi-insecure.json @@ -0,0 +1,170 @@ +{ + "schema_version": 1, + "frameworks": [ + { + "framework": "fastapi", + "confidence": "high", + "evidence": [ + "Python dependency fastapi", + "main.py imports fastapi" + ] + } + ], + "findings": [ + { + "rule_id": "SH201", + "title": "Hard-coded secret", + "severity": "high", + "category": "secrets", + "message": "Hard-coded secret found: 09d2******", + "location": { + "file": "main.py", + "line": 5, + "column": null + }, + "remediation": "Revoke the credential, move it to an environment variable or secret manager, and remove it from git history.", + "fixable": false + }, + { + "rule_id": "SH206", + "title": "Hard-coded framework secret key", + "severity": "high", + "category": "secrets", + "message": "SECRET_KEY is a hard-coded string", + "location": { + "file": "main.py", + "line": 5, + "column": null + }, + "remediation": "Load the key from an environment variable or a secret manager and fail to start when it is missing.", + "fixable": false + }, + { + "rule_id": "SH001", + "title": "Debug mode enabled", + "severity": "high", + "category": "security", + "message": "The app is started in debug mode", + "location": { + "file": "main.py", + "line": 7, + "column": null + }, + "remediation": "Set APP_DEBUG=false (and NODE_ENV=production) for every deployed environment.", + "fixable": false + }, + { + "rule_id": "SH016", + "title": "Permissive CORS in a Python, Java or .NET API", + "severity": "high", + "category": "security", + "message": "CORS allows any origin with credentials", + "location": { + "file": "main.py", + "line": 11, + "column": null + }, + "remediation": "List the exact origins that need access instead of '*'.", + "fixable": false + }, + { + "rule_id": "DOC001", + "title": "README missing or nearly empty", + "severity": "medium", + "category": "documentation", + "message": "No README found", + "location": null, + "remediation": "Describe what the project does, how to install, run and test it.", + "fixable": false + }, + { + "rule_id": "DX001", + "title": "No automated tests detected", + "severity": "medium", + "category": "developer_experience", + "message": "No test files or test directories found", + "location": null, + "remediation": "Add a test runner (Jest/Vitest, PHPUnit/Pest) and cover the critical paths.", + "fixable": false + }, + { + "rule_id": "DOC002", + "title": "No license", + "severity": "low", + "category": "documentation", + "message": "No LICENSE file found", + "location": null, + "remediation": "Add a LICENSE file (choosealicense.com can help you pick one).", + "fixable": false + }, + { + "rule_id": "DX002", + "title": "No CI workflow", + "severity": "low", + "category": "developer_experience", + "message": "No CI configuration found", + "location": null, + "remediation": "Add a pipeline (for example GitHub Actions) that runs tests and `devshield scan`.", + "fixable": true + }, + { + "rule_id": "DX008", + "title": ".gitignore missing or incomplete", + "severity": "low", + "category": "developer_experience", + "message": "No .gitignore in the project root", + "location": null, + "remediation": "Add a .gitignore for your stack that covers dependencies, build output and .env.", + "fixable": false + }, + { + "rule_id": "SH017", + "title": "API documentation exposed", + "severity": "low", + "category": "security", + "message": "FastAPI serves /docs and /openapi.json by default", + "location": { + "file": "main.py", + "line": 7, + "column": null + }, + "remediation": "Disable it in production: FastAPI(docs_url=None, redoc_url=None, openapi_url=None), or call UseSwagger only inside an IsDevelopment() check.", + "fixable": false + } + ], + "errors": [], + "warnings": [], + "rules_run": [ + "DOC001", + "DOC002", + "DOC003", + "DX001", + "DX002", + "DX007", + "DX008", + "DX009", + "PERF301", + "PERF302", + "PERF303", + "PERF304", + "SH001", + "SH013", + "SH016", + "SH017", + "SH019", + "SH020", + "SH106", + "SH107", + "SH201", + "SH202", + "SH203", + "SH205", + "SH206", + "SH301", + "SH302" + ], + "suppressed": 0, + "baselined": 0, + "score": 23, + "band": "critical" +} diff --git a/tests/snapshots/flask-insecure.json b/tests/snapshots/flask-insecure.json new file mode 100644 index 0000000..df528f9 --- /dev/null +++ b/tests/snapshots/flask-insecure.json @@ -0,0 +1,197 @@ +{ + "schema_version": 1, + "frameworks": [ + { + "framework": "flask", + "confidence": "high", + "evidence": [ + "Python dependency flask", + "app.py imports flask" + ] + } + ], + "findings": [ + { + "rule_id": "SH206", + "title": "Hard-coded framework secret key", + "severity": "high", + "category": "secrets", + "message": "secret_key is a hard-coded string", + "location": { + "file": "app.py", + "line": 8, + "column": null + }, + "remediation": "Load the key from an environment variable or a secret manager and fail to start when it is missing.", + "fixable": false + }, + { + "rule_id": "SH019", + "title": "Unsafe code execution or deserialization in Python", + "severity": "high", + "category": "security", + "message": "Code or command is built dynamically", + "location": { + "file": "app.py", + "line": 16, + "column": null + }, + "remediation": "Avoid eval and exec. Pass subprocess an argument list without shell=True, use json instead of pickle, and yaml.safe_load instead of yaml.load.", + "fixable": false + }, + { + "rule_id": "SH001", + "title": "Debug mode enabled", + "severity": "high", + "category": "security", + "message": "The app is started in debug mode", + "location": { + "file": "app.py", + "line": 21, + "column": null + }, + "remediation": "Set APP_DEBUG=false (and NODE_ENV=production) for every deployed environment.", + "fixable": false + }, + { + "rule_id": "DOC001", + "title": "README missing or nearly empty", + "severity": "medium", + "category": "documentation", + "message": "No README found", + "location": null, + "remediation": "Describe what the project does, how to install, run and test it.", + "fixable": false + }, + { + "rule_id": "DX001", + "title": "No automated tests detected", + "severity": "medium", + "category": "developer_experience", + "message": "No test files or test directories found", + "location": null, + "remediation": "Add a test runner (Jest/Vitest, PHPUnit/Pest) and cover the critical paths.", + "fixable": false + }, + { + "rule_id": "SH016", + "title": "Permissive CORS in a Python, Java or .NET API", + "severity": "medium", + "category": "security", + "message": "CORS allows any origin", + "location": { + "file": "app.py", + "line": 7, + "column": null + }, + "remediation": "List the exact origins that need access instead of '*'.", + "fixable": false + }, + { + "rule_id": "SH106", + "title": "Session cookies not hardened", + "severity": "medium", + "category": "authentication", + "message": "SESSION_COOKIE_SECURE\"] is False", + "location": { + "file": "app.py", + "line": 9, + "column": null + }, + "remediation": "Enable the Secure and HttpOnly flags for session and CSRF cookies.", + "fixable": false + }, + { + "rule_id": "SH106", + "title": "Session cookies not hardened", + "severity": "medium", + "category": "authentication", + "message": "SESSION_COOKIE_HTTPONLY\"] is False", + "location": { + "file": "app.py", + "line": 10, + "column": null + }, + "remediation": "Enable the Secure and HttpOnly flags for session and CSRF cookies.", + "fixable": false + }, + { + "rule_id": "SH107", + "title": "CSRF protection disabled", + "severity": "medium", + "category": "authentication", + "message": "WTF_CSRF_ENABLED is False", + "location": { + "file": "app.py", + "line": 11, + "column": null + }, + "remediation": "Keep CSRF protection on for cookie-based sessions; disable it only for stateless token APIs, and scope the exemption to those endpoints.", + "fixable": false + }, + { + "rule_id": "DOC002", + "title": "No license", + "severity": "low", + "category": "documentation", + "message": "No LICENSE file found", + "location": null, + "remediation": "Add a LICENSE file (choosealicense.com can help you pick one).", + "fixable": false + }, + { + "rule_id": "DX002", + "title": "No CI workflow", + "severity": "low", + "category": "developer_experience", + "message": "No CI configuration found", + "location": null, + "remediation": "Add a pipeline (for example GitHub Actions) that runs tests and `devshield scan`.", + "fixable": true + }, + { + "rule_id": "DX008", + "title": ".gitignore missing or incomplete", + "severity": "low", + "category": "developer_experience", + "message": "No .gitignore in the project root", + "location": null, + "remediation": "Add a .gitignore for your stack that covers dependencies, build output and .env.", + "fixable": false + } + ], + "errors": [], + "warnings": [], + "rules_run": [ + "DOC001", + "DOC002", + "DOC003", + "DX001", + "DX002", + "DX007", + "DX008", + "DX009", + "PERF301", + "PERF302", + "PERF303", + "PERF304", + "SH001", + "SH013", + "SH016", + "SH019", + "SH020", + "SH106", + "SH107", + "SH201", + "SH202", + "SH203", + "SH205", + "SH206", + "SH301", + "SH302" + ], + "suppressed": 0, + "baselined": 0, + "score": 18, + "band": "critical" +} diff --git a/tests/snapshots/nestjs-insecure.json b/tests/snapshots/nestjs-insecure.json new file mode 100644 index 0000000..baf0ddd --- /dev/null +++ b/tests/snapshots/nestjs-insecure.json @@ -0,0 +1,150 @@ +{ + "schema_version": 1, + "frameworks": [ + { + "framework": "node", + "confidence": "high", + "evidence": [ + "package.json" + ] + }, + { + "framework": "nestjs", + "confidence": "high", + "evidence": [ + "package.json depends on @nestjs/core" + ] + } + ], + "findings": [ + { + "rule_id": "DOC001", + "title": "README missing or nearly empty", + "severity": "medium", + "category": "documentation", + "message": "No README found", + "location": null, + "remediation": "Describe what the project does, how to install, run and test it.", + "fixable": false + }, + { + "rule_id": "DX001", + "title": "No automated tests detected", + "severity": "medium", + "category": "developer_experience", + "message": "No test files or test directories found", + "location": null, + "remediation": "Add a test runner (Jest/Vitest, PHPUnit/Pest) and cover the critical paths.", + "fixable": false + }, + { + "rule_id": "PERF301", + "title": "Lockfile missing or out of sync", + "severity": "medium", + "category": "dependencies", + "message": "package-lock.json is out of sync: @nestjs/common, @nestjs/core, @nestjs/platform-express", + "location": { + "file": "package-lock.json", + "line": null, + "column": null + }, + "remediation": "Commit the lockfile (npm install / composer install) and keep it in sync.", + "fixable": false + }, + { + "rule_id": "SH003", + "title": "Security headers not configured", + "severity": "medium", + "category": "security", + "message": "No helmet or manual security headers found", + "location": { + "file": "package.json", + "line": 5, + "column": null + }, + "remediation": "Install helmet and add `app.use(helmet())` before your routes (in NestJS, in main.ts).", + "fixable": false + }, + { + "rule_id": "SH109", + "title": "NestJS request validation not enabled", + "severity": "medium", + "category": "authentication", + "message": "No ValidationPipe is configured", + "location": { + "file": "src/main.ts", + "line": null, + "column": null + }, + "remediation": "Add `app.useGlobalPipes(new ValidationPipe({ whitelist: true }))` in main.ts, or register it with the APP_PIPE provider.", + "fixable": false + }, + { + "rule_id": "SH002", + "title": "Permissive CORS configuration", + "severity": "medium", + "category": "security", + "message": "CORS allows any origin", + "location": { + "file": "src/main.ts", + "line": 6, + "column": null + }, + "remediation": "List the exact origins that need access instead of '*' or a bare cors() call.", + "fixable": false + }, + { + "rule_id": "DX002", + "title": "No CI workflow", + "severity": "low", + "category": "developer_experience", + "message": "No CI configuration found", + "location": null, + "remediation": "Add a pipeline (for example GitHub Actions) that runs tests and `devshield scan`.", + "fixable": true + }, + { + "rule_id": "DX008", + "title": ".gitignore missing or incomplete", + "severity": "low", + "category": "developer_experience", + "message": "No .gitignore in the project root", + "location": null, + "remediation": "Add a .gitignore for your stack that covers dependencies, build output and .env.", + "fixable": false + } + ], + "errors": [], + "warnings": [], + "rules_run": [ + "DOC001", + "DOC002", + "DOC003", + "DX001", + "DX002", + "DX007", + "DX008", + "DX009", + "PERF301", + "PERF302", + "PERF303", + "PERF304", + "SH001", + "SH002", + "SH003", + "SH008", + "SH013", + "SH102", + "SH109", + "SH201", + "SH202", + "SH203", + "SH205", + "SH301", + "SH302" + ], + "suppressed": 0, + "baselined": 0, + "score": 41, + "band": "high" +} diff --git a/tests/snapshots/node-express-insecure.json b/tests/snapshots/node-express-insecure.json index 3fd100d..287addc 100644 --- a/tests/snapshots/node-express-insecure.json +++ b/tests/snapshots/node-express-insecure.json @@ -122,7 +122,7 @@ "line": 4, "column": null }, - "remediation": "Install helmet and add `app.use(helmet())` before your routes.", + "remediation": "Install helmet and add `app.use(helmet())` before your routes (in NestJS, in main.ts).", "fixable": false }, { diff --git a/tests/snapshots/node-express-insecure.txt b/tests/snapshots/node-express-insecure.txt index 8aa1a89..9e09e26 100644 --- a/tests/snapshots/node-express-insecure.txt +++ b/tests/snapshots/node-express-insecure.txt @@ -27,7 +27,7 @@ Security ⚠ SH003 Security headers not configured [medium] No helmet or manual security headers found package.json:4 - Fix: Install helmet and add `app.use(helmet())` before your routes. + Fix: Install helmet and add `app.use(helmet())` before your routes (in NestJS, in main.ts). ⚠ SH101 Insecure session cookie settings [medium] Session cookie sets secure to an unsafe value server.js:9 diff --git a/tests/snapshots/spring-clean.json b/tests/snapshots/spring-clean.json new file mode 100644 index 0000000..b6fd615 --- /dev/null +++ b/tests/snapshots/spring-clean.json @@ -0,0 +1,62 @@ +{ + "schema_version": 1, + "frameworks": [ + { + "framework": "spring", + "confidence": "high", + "evidence": [ + "pom.xml uses spring-boot" + ] + }, + { + "framework": "github_actions", + "confidence": "high", + "evidence": [ + ".github/workflows/ci.yml" + ] + } + ], + "findings": [], + "errors": [], + "warnings": [], + "rules_run": [ + "DOC001", + "DOC002", + "DOC003", + "DX001", + "DX002", + "DX003", + "DX004", + "DX005", + "DX006", + "DX007", + "DX008", + "DX009", + "DX010", + "DX011", + "PERF301", + "PERF302", + "PERF303", + "PERF304", + "SH001", + "SH013", + "SH016", + "SH018", + "SH020", + "SH106", + "SH107", + "SH108", + "SH201", + "SH202", + "SH203", + "SH205", + "SH207", + "SH301", + "SH302", + "SH303" + ], + "suppressed": 0, + "baselined": 0, + "score": 100, + "band": "low" +} diff --git a/tests/snapshots/spring-insecure.json b/tests/snapshots/spring-insecure.json new file mode 100644 index 0000000..3724254 --- /dev/null +++ b/tests/snapshots/spring-insecure.json @@ -0,0 +1,254 @@ +{ + "schema_version": 1, + "frameworks": [ + { + "framework": "spring", + "confidence": "high", + "evidence": [ + "pom.xml uses spring-boot" + ] + } + ], + "findings": [ + { + "rule_id": "SH108", + "title": "Spring Security allows every request", + "severity": "high", + "category": "authentication", + "message": "Every request is permitted without authentication", + "location": { + "file": "src/main/java/com/example/SecurityConfig.java", + "line": 14, + "column": null + }, + "remediation": "End the rule chain with anyRequest().authenticated() and permit only the specific public paths, such as the login page and static assets.", + "fixable": false + }, + { + "rule_id": "SH020", + "title": "SQL query built from string formatting", + "severity": "high", + "category": "security", + "message": "Query is built from a formatted string", + "location": { + "file": "src/main/java/com/example/UserRepository.java", + "line": 5, + "column": null + }, + "remediation": "Use parameterised queries: cursor.execute(sql, params), PreparedStatement with ?, or FromSqlInterpolated / SqlParameter in .NET.", + "fixable": false + }, + { + "rule_id": "SH207", + "title": "Credential written in an application config file", + "severity": "high", + "category": "secrets", + "message": "spring.datasource.password holds a literal secret", + "location": { + "file": "src/main/resources/application.properties", + "line": 3, + "column": null + }, + "remediation": "Reference an environment variable (${DB_PASSWORD} in Spring, environment variables or user-secrets in .NET) or a secret manager, and rotate the exposed value.", + "fixable": false + }, + { + "rule_id": "SH018", + "title": "Spring Boot management or debug endpoints exposed", + "severity": "high", + "category": "security", + "message": "The H2 database console is enabled", + "location": { + "file": "src/main/resources/application.properties", + "line": 5, + "column": null + }, + "remediation": "Expose only health and info over HTTP, put the rest behind authentication or a separate management port, and turn the H2 console and stack traces off.", + "fixable": false + }, + { + "rule_id": "SH018", + "title": "Spring Boot management or debug endpoints exposed", + "severity": "high", + "category": "security", + "message": "Actuator exposes all endpoints", + "location": { + "file": "src/main/resources/application.properties", + "line": 6, + "column": null + }, + "remediation": "Expose only health and info over HTTP, put the rest behind authentication or a separate management port, and turn the H2 console and stack traces off.", + "fixable": false + }, + { + "rule_id": "DOC001", + "title": "README missing or nearly empty", + "severity": "medium", + "category": "documentation", + "message": "No README found", + "location": null, + "remediation": "Describe what the project does, how to install, run and test it.", + "fixable": false + }, + { + "rule_id": "DX001", + "title": "No automated tests detected", + "severity": "medium", + "category": "developer_experience", + "message": "No test files or test directories found", + "location": null, + "remediation": "Add a test runner (Jest/Vitest, PHPUnit/Pest) and cover the critical paths.", + "fixable": false + }, + { + "rule_id": "SH016", + "title": "Permissive CORS in a Python, Java or .NET API", + "severity": "medium", + "category": "security", + "message": "CORS allows any origin", + "location": { + "file": "src/main/java/com/example/SecurityConfig.java", + "line": 7, + "column": null + }, + "remediation": "List the exact origins that need access instead of '*'.", + "fixable": false + }, + { + "rule_id": "SH107", + "title": "CSRF protection disabled", + "severity": "medium", + "category": "authentication", + "message": "Spring Security CSRF protection is disabled", + "location": { + "file": "src/main/java/com/example/SecurityConfig.java", + "line": 12, + "column": null + }, + "remediation": "Keep CSRF protection on for cookie-based sessions; disable it only for stateless token APIs, and scope the exemption to those endpoints.", + "fixable": false + }, + { + "rule_id": "SH303", + "title": "Hibernate changes the database schema at startup", + "severity": "medium", + "category": "configuration", + "message": "ddl-auto is update", + "location": { + "file": "src/main/resources/application.properties", + "line": 4, + "column": null + }, + "remediation": "Set spring.jpa.hibernate.ddl-auto to validate or none and manage the schema with Flyway or Liquibase.", + "fixable": false + }, + { + "rule_id": "SH018", + "title": "Spring Boot management or debug endpoints exposed", + "severity": "medium", + "category": "security", + "message": "Health details are shown to everyone", + "location": { + "file": "src/main/resources/application.properties", + "line": 7, + "column": null + }, + "remediation": "Expose only health and info over HTTP, put the rest behind authentication or a separate management port, and turn the H2 console and stack traces off.", + "fixable": false + }, + { + "rule_id": "SH018", + "title": "Spring Boot management or debug endpoints exposed", + "severity": "medium", + "category": "security", + "message": "Error responses include stack traces", + "location": { + "file": "src/main/resources/application.properties", + "line": 8, + "column": null + }, + "remediation": "Expose only health and info over HTTP, put the rest behind authentication or a separate management port, and turn the H2 console and stack traces off.", + "fixable": false + }, + { + "rule_id": "SH106", + "title": "Session cookies not hardened", + "severity": "medium", + "category": "authentication", + "message": "server.servlet.session.cookie.secure is false", + "location": { + "file": "src/main/resources/application.properties", + "line": 9, + "column": null + }, + "remediation": "Enable the Secure and HttpOnly flags for session and CSRF cookies.", + "fixable": false + }, + { + "rule_id": "DOC002", + "title": "No license", + "severity": "low", + "category": "documentation", + "message": "No LICENSE file found", + "location": null, + "remediation": "Add a LICENSE file (choosealicense.com can help you pick one).", + "fixable": false + }, + { + "rule_id": "DX002", + "title": "No CI workflow", + "severity": "low", + "category": "developer_experience", + "message": "No CI configuration found", + "location": null, + "remediation": "Add a pipeline (for example GitHub Actions) that runs tests and `devshield scan`.", + "fixable": true + }, + { + "rule_id": "DX008", + "title": ".gitignore missing or incomplete", + "severity": "low", + "category": "developer_experience", + "message": "No .gitignore in the project root", + "location": null, + "remediation": "Add a .gitignore for your stack that covers dependencies, build output and .env.", + "fixable": false + } + ], + "errors": [], + "warnings": [], + "rules_run": [ + "DOC001", + "DOC002", + "DOC003", + "DX001", + "DX002", + "DX007", + "DX008", + "DX009", + "PERF301", + "PERF302", + "PERF303", + "PERF304", + "SH001", + "SH013", + "SH016", + "SH018", + "SH020", + "SH106", + "SH107", + "SH108", + "SH201", + "SH202", + "SH203", + "SH205", + "SH207", + "SH301", + "SH302", + "SH303" + ], + "suppressed": 0, + "baselined": 0, + "score": 11, + "band": "critical" +} diff --git a/tests/snapshots/terraform-clean.json b/tests/snapshots/terraform-clean.json new file mode 100644 index 0000000..91ec881 --- /dev/null +++ b/tests/snapshots/terraform-clean.json @@ -0,0 +1,40 @@ +{ + "schema_version": 1, + "frameworks": [ + { + "framework": "terraform", + "confidence": "high", + "evidence": [ + "main.tf" + ] + } + ], + "findings": [], + "errors": [], + "warnings": [], + "rules_run": [ + "DX007", + "DX009", + "PERF301", + "PERF302", + "PERF303", + "PERF304", + "SH001", + "SH013", + "SH201", + "SH202", + "SH203", + "SH205", + "SH301", + "SH302", + "SH401", + "SH402", + "SH403", + "SH404", + "SH405" + ], + "suppressed": 0, + "baselined": 0, + "score": 100, + "band": "low" +} diff --git a/tests/snapshots/terraform-insecure.json b/tests/snapshots/terraform-insecure.json new file mode 100644 index 0000000..9f82366 --- /dev/null +++ b/tests/snapshots/terraform-insecure.json @@ -0,0 +1,111 @@ +{ + "schema_version": 1, + "frameworks": [ + { + "framework": "terraform", + "confidence": "high", + "evidence": [ + "main.tf" + ] + } + ], + "findings": [ + { + "rule_id": "SH402", + "title": "IAM policy grants wildcard permissions", + "severity": "critical", + "category": "security", + "message": "Allows every action on every resource", + "location": { + "file": "main.tf", + "line": 12, + "column": null + }, + "remediation": "List the specific actions and resources the workload needs (least privilege).", + "fixable": false + }, + { + "rule_id": "SH401", + "title": "S3 bucket is publicly accessible", + "severity": "high", + "category": "security", + "message": "assets uses the public-read ACL", + "location": { + "file": "main.tf", + "line": 7, + "column": null + }, + "remediation": "Use a private ACL, enable all four settings of the public access block, and grant access with specific principals or CloudFront origin access control.", + "fixable": false + }, + { + "rule_id": "SH403", + "title": "Security group open to the internet", + "severity": "high", + "category": "security", + "message": "Ingress from the internet to port 22", + "location": { + "file": "main.tf", + "line": 25, + "column": null + }, + "remediation": "Restrict the source to your own CIDR range or a security group, and use SSM Session Manager or a VPN instead of public SSH.", + "fixable": false + }, + { + "rule_id": "SH405", + "title": "Database reachable from the internet", + "severity": "high", + "category": "security", + "message": "main has publicly_accessible = true", + "location": { + "file": "main.tf", + "line": 36, + "column": null + }, + "remediation": "Set publicly_accessible = false and reach the database through a private subnet, a VPN or a bastion host.", + "fixable": false + }, + { + "rule_id": "SH404", + "title": "Storage encryption at rest not enabled", + "severity": "medium", + "category": "security", + "message": "main does not set storage_encrypted = true", + "location": { + "file": "main.tf", + "line": 33, + "column": null + }, + "remediation": "Set storage_encrypted = true (RDS) or encrypted = true (EBS), ideally with a KMS key.", + "fixable": false + } + ], + "errors": [], + "warnings": [], + "rules_run": [ + "DX007", + "DX009", + "PERF301", + "PERF302", + "PERF303", + "PERF304", + "SH001", + "SH013", + "SH201", + "SH202", + "SH203", + "SH205", + "SH301", + "SH302", + "SH401", + "SH402", + "SH403", + "SH404", + "SH405" + ], + "suppressed": 0, + "baselined": 0, + "score": 27, + "band": "critical" +} diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py index 358d9fe..7e90ea2 100644 --- a/tests/test_fixtures.py +++ b/tests/test_fixtures.py @@ -51,6 +51,7 @@ def test_terminal_snapshot(fixtures: Path, name: str) -> None: def test_clean_fixtures_have_no_findings(fixtures: Path) -> None: - for name in ("laravel-clean", "empty"): + clean = [*sorted(p.name for p in FIXTURES.iterdir() if p.name.endswith("-clean")), "empty"] + for name in clean: result = scan_fixture(fixtures, name) assert result.findings == () and result.score == 100 diff --git a/tests/test_rule_docs.py b/tests/test_rule_docs.py index 1c5b9f1..4b9d08e 100644 --- a/tests/test_rule_docs.py +++ b/tests/test_rule_docs.py @@ -2,7 +2,7 @@ from pathlib import Path -from devshield.reports.rule_docs import render_all +from devshield.reports.rule_docs import render_all, render_frameworks_page from tests.rules.helpers import REGISTRY @@ -16,3 +16,10 @@ def test_generated_rule_docs_are_up_to_date() -> None: assert (DOCS / name).read_text(encoding="utf-8") == content, ( f"{name} is stale: run `python scripts/gen_rule_docs.py`" ) + + +def test_frameworks_page_is_up_to_date() -> None: + page = DOCS.parent / "frameworks.md" + assert page.read_text(encoding="utf-8") == render_frameworks_page(REGISTRY.all()), ( + "docs/frameworks.md is stale: run `python scripts/gen_rule_docs.py`" + )