You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by as...@apache.org on 2026/04/22 03:29:35 UTC
(tooling-agents) branch main updated: ASVS L3 audit: 14.1.1 (sensitive_data_handling)
This is an automated email from the ASF dual-hosted git repository.
asf-tooling pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-agents.git
The following commit(s) were added to refs/heads/main by this push:
new 99a3809 ASVS L3 audit: 14.1.1 (sensitive_data_handling)
99a3809 is described below
commit 99a3809876797dc2401401788984eb84ede5ebf4
Author: ASF Tooling Team <ro...@apache.org>
AuthorDate: Wed Apr 22 05:29:31 2026 +0200
ASVS L3 audit: 14.1.1 (sensitive_data_handling)
---
.../v3/d0aa7e9/sensitive_data_handling/14.1.1.md | 1344 ++++++++++++++++++++
1 file changed, 1344 insertions(+)
diff --git a/repos/apache/ASVS/reports/steve/v3/d0aa7e9/sensitive_data_handling/14.1.1.md b/repos/apache/ASVS/reports/steve/v3/d0aa7e9/sensitive_data_handling/14.1.1.md
new file mode 100644
index 0000000..93654a6
--- /dev/null
+++ b/repos/apache/ASVS/reports/steve/v3/d0aa7e9/sensitive_data_handling/14.1.1.md
@@ -0,0 +1,1344 @@
+# ASVS 14.1.1 Security Audit Report
+
+## Executive Summary
+
+**Repository:** apache/steve
+**Audit Date:** Apr 22, 2026
+**Auditor:** Tooling Agents
+**ASVS Requirement:** 14.1.1 — Verify that all sensitive data created and processed by the application has been identified and classified into protection levels. This includes data that is only encoded and therefore easily decoded, such as Base64 strings or the plaintext payload inside a JWT. Protection levels need to take into account any data protection and privacy regulations and standards which the application is required to comply with.
+**Level:** 2
+**Section:** Data Protection Documentation
+
+**Files Analyzed:** 20 relevant out of 20 total, 0 skipped
+
+**Finding Counts:**
+
+| Severity | Count |
+|----------|-------|
+| Critical | 0 |
+| High | 1 |
+| Medium | 3 |
+| Low | 0 |
+| **Total** | **4** |
+
+**Assessment Summary:**
+
+ASVS 14.1.1 requires that all sensitive data created and processed by the application has been identified and classified into protection levels. This voting system handles multiple categories of highly sensitive data — encrypted votes, cryptographic keying material, per-voter salts, voter PII, and election metadata — where the core security goal is ballot secrecy. While the codebase contains several positive ad-hoc protections (salt exclusion from public APIs, vote encryption), there is [...]
+
+---
+
+## Security Controls Inventory
+
+### Data Protection Controls — Coverage Analysis
+
+| Control | Location | Purpose | Coverage Status |
+|---------|----------|---------|-----------------|
+| **Salt exclusion from public APIs** | `election.py`: `get_metadata()` (line 146-157)<br>`election.py`: `get_issue()` (line 163)<br>`persondb.py`: `get_person()` (line 38) | Prevent exposure of cryptographic material (election.salt, election.opened_key, mayvote.salt) | ⚠️ **Partial** — Applied at data access layer but no template-boundary defense-in-depth filter (Type B gap) |
+| **Vote encryption** | `election.py`: `add_vote()` (line 239-253) via `crypto.create_vote()` | Protect vote content before storage | ✅ **Complete** — Applied consistently before database insertion |
+| **Vote decryption restriction** | `election.py`: `tally_issue()` (line 255-310) | Prevent premature tallying; requires `S_CLOSED` state | ✅ **Complete** — Applied via state assertion |
+| **Session authentication** | `pages.py`: `@asfquart.auth.require()` decorators (various endpoints) | Restrict access to authenticated users with appropriate roles | ✅ **Complete** — Applied to all data-access endpoints |
+| **Election ID unpredictability** | `election.py`: `crypto.create_id()` | Prevent election enumeration via unpredictable 10-char hex IDs | ⚠️ **Undermined** — Effectiveness reduced by GET-method exposure in logs/history (SEV-004) |
+| **Cache-control headers** | N/A | Prevent sensitive election data from being cached in browsers/proxies | ❌ **Missing** — No control exists (Type A gap, SEV-003) |
+| **Form data classification** | N/A | Define handling rules for form input containing sensitive election data | ❌ **Missing** — No control exists (Type A gap, SEV-002) |
+| **Log sanitization** | N/A | Prevent sensitive data from appearing in application logs | ❌ **Missing** — No control exists (Type A gap, SEV-002) |
+
+### Gap Type Summary
+
+| Gap Type | Count | Description |
+|----------|-------|-------------|
+| **Type A** (No control exists) | 3 | Cache-control headers, form data classification, log sanitization |
+| **Type B** (Control exists but incomplete coverage) | 1 | Salt exclusion exists at data layer but no template-boundary enforcement |
+
+---
+
+## Critical File Review
+
+### `v3/server/pages.py` — Web Request Handlers
+
+| Line(s) | Function | Sensitive Data Present | Protection Applied | Security Concern |
+|---------|----------|------------------------|-------------------|------------------|
+| 57-88 | `basic_info()` | Session uid, name, email | Session authentication | CSRF token is placeholder value |
+| 137 | `voter_page()` | Election lists, voter eligibility | Authentication required | No cache-control headers (SEV-003) |
+| 226-282 | `vote_on_page()` | Election metadata, candidate lists, issue descriptions | Authentication, mayvote check | No cache-control headers (SEV-003) |
+| 285 | `admin_page()` | Owned elections, administrative state | Authentication required | No cache-control headers (SEV-003) |
+| 322 | `manage_page()` | Election configuration, issue details | Authentication required | No cache-control headers (SEV-003) |
+| 380-416 | `do_vote_endpoint()` | Vote strings (form data) | Authentication, encryption via `add_vote()` | Error logging with `{e}` may leak data |
+| 439-455 | `do_open_endpoint()` | Election ID, opened_key generation | Authentication, state assertion | **GET method exposes EID in logs/history (SEV-004)** |
+| 457 | `do_close_endpoint()` | Election ID | Authentication, state assertion | **GET method exposes EID in logs/history (SEV-004)** |
+| ~487 | `do_add_issue_endpoint()` | Form data (titles, descriptions) | Authentication | **`print('FORM:', form)` logs all form data (SEV-002)** |
+| ~507 | `do_edit_issue_endpoint()` | Form data (titles, descriptions) | Authentication | **`print('FORM:', form)` logs all form data (SEV-002)** |
+| 603 | `postprocess_election()` | Election data (all fields) | None | Processes all fields without filtering sensitive data |
+
+### `v3/steve/election.py` — Core Election Logic
+
+| Line(s) | Function | Sensitive Data Present | Protection Applied | Security Concern |
+|---------|----------|------------------------|-------------------|------------------|
+| 72-82 | `open()` | Salt, opened_key (cryptographic material) | State assertion (`is_editable()`), stored in DB | Cryptographic key material held in memory during processing |
+| 121-139 | `add_salts()` | Per-voter salts | Transaction boundary, state assertion | ✅ Correctly scoped with atomic transaction |
+| 146-157 | `get_metadata()` | **Excludes** salt/opened_key | Explicit field selection | ✅ **Positive pattern** — Documented exclusion |
+| 163 | `get_issue()` | Issue data, **excludes** salt | Comment: "NEVER return issue.salt" | ✅ **Positive pattern** — Comment-driven exclusion |
+| 239-253 | `add_vote()` | Votestring (plaintext), vote_token, ciphertext | State check (`is_open()`), encryption before storage | Votestring validation marked as TODO |
+| 255-310 | `tally_issue()` | Decrypted votestrings | `S_CLOSED` state requirement | ✅ Votes shuffled before return to prevent order correlation |
+
+### `v3/steve/persondb.py` — Person Database
+
+| Line(s) | Function | Sensitive Data Present | Protection Applied | Security Concern |
+|---------|----------|------------------------|-------------------|------------------|
+| 35-42 | `get_person()` | PII (name, email) | Comment: "NEVER return salt" | Returns email even when only name is needed by most callers |
+| 67-71 | `list_persons()` | All PII (pid, name, email) | None | Bulk PII access without classification or access control justification |
+
+### `v3/schema.sql` — Database Schema
+
+| Table | Sensitive Columns | Protection Mechanism | Security Concern |
+|-------|-------------------|---------------------|------------------|
+| `election` | `salt`, `opened_key` | Application-layer exclusion in `get_metadata()` | No database-level access control |
+| `mayvote` | `salt` | Application-layer exclusion in `get_issue()` | No database-level access control |
+| `vote` | `vote_token`, `ciphertext` | Encryption (ciphertext), internal-only (vote_token) | Vote tokens not explicitly classified |
+| `person` | `email`, `name` | None | PII returned without classification-based filtering |
+
+---
+
+## Findings
+
+### ASVS-1411-HIGH-001: Absence of Formal Sensitive Data Classification and Protection Levels
+
+**Severity:** HIGH
+
+**Location:**
+- **File:** `v3/steve/election.py`
+ - **Function:** `get_metadata()` — lines 146-157
+ - **Function:** `get_issue()` — line 163 (comment)
+- **File:** `v3/steve/persondb.py`
+ - **Function:** `get_person()` — line 38 (comment)
+- **File:** `v3/server/pages.py`
+ - **Function:** `basic_info()` — line 57
+ - **Function:** `postprocess_election()` — line 603
+- **File:** `v3/schema.sql` — schema comments (various)
+
+**Related Functions Checked:**
+- `election.py`: `_all_metadata()`, `open()`, `add_salts()`, `add_vote()`, `tally_issue()`
+- `persondb.py`: `list_persons()`
+- `pages.py`: `owned_elections()` (line 416-425), `vote_on_page()`, `do_vote_endpoint()`
+
+**Description:**
+
+The system processes at least six distinct categories of sensitive data, each requiring different protection levels, but none are formally classified. Ad-hoc protections exist (salt exclusion in specific functions, vote encryption) but there is no systematic framework to ensure consistent handling across all code paths.
+
+**Sensitive Data Categories Identified:**
+
+| Data Category | Sensitivity Level | Current Ad-Hoc Protection | Formal Classification |
+|---------------|-------------------|---------------------------|----------------------|
+| Election cryptographic salt/opened_key | CRITICAL | `get_metadata()` excludes them | ❌ None |
+| Per-voter salts (`mayvote.salt`) | CRITICAL | `get_issue()` comment: "NEVER return" | ❌ None |
+| Vote content (plaintext votestring) | CRITICAL | Encrypted before storage via `add_vote()` | ❌ None |
+| Vote tokens (`vote.vote_token`) | HIGH | Internal-only processing | ❌ None |
+| Voter PII (`person.email`, `person.name`) | MEDIUM (privacy) | No explicit protection level | ❌ None |
+| Election metadata (eid, authz, state) | LOW-MEDIUM | No explicit protection level | ❌ None |
+
+**Vulnerable Code — Inconsistent Ad-Hoc Protections:**
+
+In `election.py`, `get_metadata()` correctly excludes sensitive fields:
+```python
+# lines 146-157
+def get_metadata(self):
+ "Return basic metadata about this Election."
+ md = self._all_metadata()
+ # NOTE: do not return the SALT or OPENED_KEY
+ return edict(
+ eid=md.eid, title=md.title, owner_pid=md.owner_pid,
+ authz=md.authz, closed=md.closed,
+ open_at=md.open_at, close_at=md.close_at,
+ state=self._compute_state(md),
+ )
+```
+
+However, `postprocess_election()` in `pages.py` accepts and processes election data without verifying sensitive fields are absent:
+```python
+# line 603
+def postprocess_election(e):
+ "Post-process attributes in an Election, as an EasyDict."
+ # NOTE: side effects. This function manipulates the E argument.
+ e.closed = ezt.boolean(e.closed == 1)
+ # ... processes all fields in `e` without filtering
+```
+
+Similarly, `owned_elections()` relies on code comments rather than enforcement:
+```python
+# lines 416-425
+@classmethod
+def owned_elections(cls, db_fname, pid):
+ db = cls.open_database(db_fname)
+ # NOTE: contains subset of columns. We don't want to return the
+ # SALT or OPENED_KEY values.
+ db.q_owned.perform(pid,)
+ return [row for row in db.q_owned.fetchall()]
+```
+
+**Data Flow:**
+
+```
+_all_metadata() [returns salt/opened_key]
+ ↓
+Various query results (q_open_to_me, q_owned)
+ ↓
+postprocess_election() [no sensitive field filter]
+ ↓
+Template context dictionary
+ ↓
+HTML response to client
+```
+
+**Proof of Concept:**
+
+If any upstream query (e.g., `q_open_to_me`, `q_owned`) were modified to include `salt` or `opened_key` columns:
+
+1. The database would return these fields
+2. `postprocess_election()` would process them without filtering
+3. The template context would contain cryptographic material
+4. Depending on template implementation, this could be rendered in HTML
+
+**Impact:**
+
+Without formal classification, there is no systematic way to verify that all sensitive data types are consistently protected across all code paths. The current protections are:
+
+- **Convention-based** — Individual developers must remember to exclude sensitive fields
+- **Comment-driven** — Security decisions documented in comments rather than enforced by code
+- **Function-specific** — No defense-in-depth at architectural boundaries (e.g., template rendering layer)
+
+**Consequences:**
+
+1. New features or query modifications could inadvertently expose cryptographic material or voter data
+2. The ballot secrecy guarantee cannot be verified as complete
+3. Compliance with data protection regulations cannot be demonstrated
+4. Code reviews cannot systematically verify protection level adherence
+5. Refactoring may accidentally remove ad-hoc protections
+
+**Remediation:**
+
+**Step 1: Create formal data classification document**
+
+```python
+# data_classification.py
+
+class DataClassification:
+ """
+ Formal data classification levels for the STeVe voting system.
+ All data must be assigned a classification level with specific handling rules.
+ """
+
+ # CRITICAL: Never exposed outside secure processing context
+ CRITICAL = 'critical'
+ CRITICAL_FIELDS = {
+ 'salt', # Election cryptographic salt
+ 'opened_key', # Election decryption key
+ 'vote_token', # Per-vote authentication token
+ 'ciphertext', # Encrypted vote content
+ 'votestring', # Plaintext vote (only in memory during encryption/decryption)
+ }
+
+ # SENSITIVE: Encrypted in transit and at rest, access-controlled
+ SENSITIVE = 'sensitive'
+ SENSITIVE_FIELDS = {
+ 'email', # Voter PII
+ 'name', # Voter PII (less sensitive than email)
+ }
+
+ # INTERNAL: Not public but not secret, authenticated access only
+ INTERNAL = 'internal'
+ INTERNAL_FIELDS = {
+ 'eid', # Election ID (unpredictable but not secret once known)
+ 'iid', # Issue ID
+ 'pid', # Person ID
+ 'authz', # Authorization level
+ 'state', # Election state
+ 'owner_pid', # Election owner
+ }
+
+ # PUBLIC: Freely visible
+ PUBLIC = 'public'
+ PUBLIC_FIELDS = {
+ 'title', # Election title
+ 'description', # Issue description
+ 'open_at', # Election schedule
+ 'close_at', # Election schedule
+ }
+
+ @classmethod
+ def get_classification(cls, field_name):
+ """Return the classification level for a given field name."""
+ if field_name in cls.CRITICAL_FIELDS:
+ return cls.CRITICAL
+ elif field_name in cls.SENSITIVE_FIELDS:
+ return cls.SENSITIVE
+ elif field_name in cls.INTERNAL_FIELDS:
+ return cls.INTERNAL
+ elif field_name in cls.PUBLIC_FIELDS:
+ return cls.PUBLIC
+ else:
+ # Default to INTERNAL for unclassified fields (fail-secure)
+ return cls.INTERNAL
+```
+
+**Step 2: Add defense-in-depth filtering at template boundary**
+
+```python
+# pages.py
+
+from data_classification import DataClassification
+
+def sanitize_for_template(data):
+ """
+ Remove CRITICAL-classified fields before template rendering.
+ Defense-in-depth: Even if upstream code accidentally includes sensitive fields,
+ prevent them from reaching templates.
+ """
+ if isinstance(data, dict):
+ forbidden = DataClassification.CRITICAL_FIELDS
+ for field in forbidden:
+ if field in data:
+ _LOGGER.warning(
+ 'SECURITY: Removed CRITICAL field [%s] at template boundary',
+ field
+ )
+ data.pop(field)
+ elif isinstance(data, (list, tuple)):
+ return [sanitize_for_template(item) for item in data]
+ return data
+
+# Apply to all template rendering
+@APP.use_template(TEMPLATES / 'vote-on.ezt')
+async def vote_on_page(election):
+ result = await basic_info()
+ # ... populate result ...
+ return sanitize_for_template(result) # Add sanitization
+```
+
+**Step 3: Update `postprocess_election()` with classification awareness**
+
+```python
+def postprocess_election(e):
+ """
+ Post-process attributes in an Election, as an EasyDict.
+ Verify no CRITICAL fields are present (defense-in-depth).
+ """
+ # Verify no CRITICAL fields leaked through
+ forbidden = DataClassification.CRITICAL_FIELDS
+ present_forbidden = forbidden & set(e.keys())
+ if present_forbidden:
+ raise SecurityError(
+ f'CRITICAL fields present in election data: {present_forbidden}'
+ )
+
+ e.closed = ezt.boolean(e.closed == 1)
+ # ... rest of processing ...
+```
+
+**Step 4: Add classification verification tests**
+
+```python
+# test_data_classification.py
+
+def test_get_metadata_excludes_critical_fields():
+ """Verify get_metadata() excludes all CRITICAL fields."""
+ election = Election(...)
+ metadata = election.get_metadata()
+
+ forbidden = DataClassification.CRITICAL_FIELDS
+ present = forbidden & set(metadata.keys())
+
+ assert not present, f'CRITICAL fields in metadata: {present}'
+
+def test_template_sanitization():
+ """Verify template sanitizer removes CRITICAL fields."""
+ data = {
+ 'title': 'Test Election',
+ 'salt': 'abc123', # Should be removed
+ 'opened_key': 'def456', # Should be removed
+ }
+
+ sanitized = sanitize_for_template(data)
+
+ assert 'title' in sanitized
+ assert 'salt' not in sanitized
+ assert 'opened_key' not in sanitized
+```
+
+---
+
+### ASVS-1411-MED-002: Debug Logging of Unclassified Form Data to Standard Output
+
+**Severity:** MEDIUM
+
+**Location:**
+- **File:** `v3/server/pages.py`
+ - **Function:** `do_add_issue_endpoint()` — line ~487
+ - **Function:** `do_edit_issue_endpoint()` — line ~507
+
+**Related Functions Checked:**
+- `basic_info()` — line 57
+- `load_election` decorator
+- `load_election_issue` decorator
+
+**Vulnerable Code:**
+
+```python
+@APP.post('/do-add-issue/<eid>')
+@asfquart.auth.require({R.committer})
+@load_election
+async def do_add_issue_endpoint(election):
+ result = await basic_info()
+ form = edict(await quart.request.form)
+ print('FORM:', form) # <-- Dumps all form fields to stdout
+ # ... process form ...
+```
+
+```python
+@APP.post('/do-edit-issue/<eid>/<iid>')
+@asfquart.auth.require({R.committer})
+@load_election_issue
+async def do_edit_issue_endpoint(election, issue):
+ result = await basic_info()
+ form = edict(await quart.request.form)
+ print('FORM:', form) # <-- Dumps all form fields to stdout
+ # ... process form ...
+```
+
+**Data Flow:**
+
+```
+HTTP POST request body
+ ↓
+quart.request.form (ImmutableMultiDict)
+ ↓
+edict() conversion
+ ↓
+print() to stdout
+ ↓
+Container/server logs (uncontrolled retention)
+ ↓
+Log aggregation systems (Docker, Kubernetes, CloudWatch)
+ ↓
+Accessible to operators/administrators
+```
+
+**Proof of Concept:**
+
+```http
+POST /do-add-issue/abc1234567 HTTP/1.1
+Host: vote.example.org
+Content-Type: application/x-www-form-urlencoded
+Cookie: session=...
+
+title=Board+Election+2026&description=Confidential+candidate+slate:+Alice,+Bob,+Charlie&category=stv&csrf_token=placeholder
+```
+
+**Stdout Output:**
+```
+FORM: {
+ 'title': 'Board Election 2026',
+ 'description': 'Confidential candidate slate: Alice, Bob, Charlie',
+ 'category': 'stv',
+ 'csrf_token': 'placeholder'
+}
+```
+
+**Impact:**
+
+All form data — including issue titles, descriptions (which may contain confidential candidate information or election details), and any future form fields — is written to stdout with uncontrolled retention characteristics:
+
+1. **Container Logs:** In Docker/Kubernetes deployments, stdout is captured by the container runtime and stored in log files
+2. **Log Aggregation:** Logs are typically forwarded to centralized systems (ELK stack, Splunk, CloudWatch) with extended retention
+3. **Access Control:** Log access is often granted to operations teams who should not see election content
+4. **Compliance:** Uncontrolled logging of election data may violate data protection regulations
+
+**Specific Sensitive Data Exposed:**
+
+| Form Field | Sensitivity | Why Sensitive |
+|------------|-------------|---------------|
+| `title` | INTERNAL | Election/issue titles may reveal confidential organizational information |
+| `description` | INTERNAL-SENSITIVE | May contain candidate names, voting procedures, confidential context |
+| `category` | LOW | Voting method (yn, stv) is not sensitive |
+| Future fields | UNKNOWN | Any future form fields will be automatically logged without review |
+
+**Demonstration that Form Data is Unclassified:**
+
+The presence of `print()` statements demonstrates that form data has not been assigned a protection level with handling rules. A classified data handling policy would explicitly prohibit logging of form input containing election data.
+
+**Remediation:**
+
+**Immediate: Remove debug print statements**
+
+```python
+@APP.post('/do-add-issue/<eid>')
+@asfquart.auth.require({R.committer})
+@load_election
+async def do_add_issue_endpoint(election):
+ result = await basic_info()
+ form = edict(await quart.request.form)
+ # REMOVED: print('FORM:', form)
+
+ # If logging is needed for debugging, log only non-sensitive metadata:
+ _LOGGER.debug(
+ 'Issue creation requested for election[E:%s] by user[U:%s]',
+ election.eid,
+ result.uid
+ )
+ # Never log form field values in production
+```
+
+**Short-term: Implement structured logging with field filtering**
+
+```python
+# logging_config.py
+
+class SensitiveFieldFilter(logging.Filter):
+ """
+ Filter that removes sensitive fields from log records.
+ """
+ SENSITIVE_KEYS = {
+ 'title', 'description', 'votestring', 'salt',
+ 'opened_key', 'vote_token', 'email', 'csrf_token'
+ }
+
+ def filter(self, record):
+ # If log record contains a dict, sanitize it
+ if hasattr(record, 'form_data'):
+ record.form_data = self._sanitize_dict(record.form_data)
+ return True
+
+ def _sanitize_dict(self, data):
+ if not isinstance(data, dict):
+ return data
+ return {
+ k: ('***REDACTED***' if k in self.SENSITIVE_KEYS else v)
+ for k, v in data.items()
+ }
+
+# Apply filter to all loggers
+logging.getLogger().addFilter(SensitiveFieldFilter())
+```
+
+**Long-term: Add data classification to logging policy**
+
+```python
+# data_classification.py (extend)
+
+class DataClassification:
+ # ... existing code ...
+
+ @classmethod
+ def is_loggable(cls, field_name, value=None):
+ """
+ Determine if a field can be logged based on its classification.
+
+ Rules:
+ - CRITICAL: Never log
+ - SENSITIVE: Never log
+ - INTERNAL: Log only field name, not value
+ - PUBLIC: Log freely
+ """
+ classification = cls.get_classification(field_name)
+
+ if classification in (cls.CRITICAL, cls.SENSITIVE):
+ return False
+ elif classification == cls.INTERNAL:
+ return 'name_only'
+ else: # PUBLIC
+ return True
+```
+
+---
+
+### ASVS-1411-MED-003: Missing Cache-Control Headers on Pages Processing Sensitive Election and Voter Data
+
+**Severity:** MEDIUM
+
+**Location:**
+- **File:** `v3/server/pages.py`
+ - **Function:** `vote_on_page()` — line ~226
+ - **Function:** `voter_page()` — line ~137
+ - **Function:** `admin_page()` — line ~285
+ - **Function:** `manage_page()` — line ~322
+- **File:** `v3/server/templates/header.ezt` — no cache meta tags
+- **File:** `v3/server/templates/vote-on.ezt` — renders election details, candidate data
+
+**Related Functions Checked:**
+- `basic_info()` — line 57
+- `postprocess_election()` — line 603
+- All `@APP.use_template()` decorated endpoints
+
+**Description:**
+
+Pages that display sensitive election data — voter eligibility, candidate lists, election structure, and voting interfaces — are served without any `Cache-Control` headers or equivalent meta tags. The domain context explicitly states that "cache-control headers prevent vote caching" as a required control.
+
+**Evidence — `vote_on_page()` Serves Sensitive Data Without Cache Controls:**
+
+```python
+# lines 226-282
+@APP.get('/vote-on/<eid>')
+@asfquart.auth.require({R.committer})
+@load_election
+@APP.use_template(TEMPLATES / 'vote-on.ezt')
+async def vote_on_page(election):
+ result = await basic_info()
+
+ # Populate with sensitive election data
+ result.election = election.get_metadata()
+ all_issues = election.list_issues()
+
+ # ... processes candidates, descriptions, voter eligibility ...
+
+ return result # No Cache-Control headers set
+```
+
+**Evidence — `header.ezt` Contains No Cache Directives:**
+
+```html
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Apache STeVe: [title]</title>
+ <!-- No <meta http-equiv="Cache-Control" content="no-store"> -->
+ <!-- No <meta http-equiv="Pragma" content="no-cache"> -->
+ <!-- No <meta http-equiv="Expires" content="0"> -->
+```
+
+**Data Exposed on Cached Pages:**
+
+| Page Endpoint | Sensitive Data Cached | Classification Level |
+|---------------|----------------------|---------------------|
+| `/vote-on/<eid>` | Election title, all candidate names (STV), issue descriptions, voter eligibility proof | INTERNAL + SENSITIVE |
+| `/voter` | List of elections user is eligible for, election metadata, voting status | INTERNAL |
+| `/admin` | Owned elections, administrative state information | INTERNAL |
+| `/manage/<eid>` | Election configuration, issue details, state machine position | INTERNAL |
+| `/profile` | User PII (name, email) | SENSITIVE |
+
+**Data Flow — Caching Attack:**
+
+```
+User authenticates and visits /vote-on/abc1234567
+ ↓
+Server renders page with candidate list
+ ↓
+Browser caches response (no Cache-Control: no-store)
+ ↓
+User logs out or session expires
+ ↓
+Attacker with physical access presses "Back" button
+ ↓
+Browser serves cached page from disk/memory
+ ↓
+Attacker sees candidate list and election structure
+```
+
+**Proof of Concept:**
+
+1. Authenticate as a voter and navigate to `/vote-on/abc1234567`
+2. Observe response headers:
+ ```http
+ HTTP/1.1 200 OK
+ Content-Type: text/html; charset=utf-8
+ Content-Length: 4523
+
+ <!-- No Cache-Control header -->
+ ```
+3. Log out or close browser
+4. Press browser "Back" button
+5. **Result:** Cached page is displayed with full election data, despite no active session
+
+**Impact:**
+
+Browser back-button, history, and proxy caches can reveal:
+
+1. **Voter Participation:** Which elections a voter participated in (confirming voter eligibility is itself sensitive metadata in organizational contexts)
+2. **Election Structure:** Full candidate lists and election structure before voting is complete
+3. **Administrative Access:** Whether the user was viewing the page in an administrative or voting capacity
+4. **PII:** User profile information cached on shared computers
+
+**Attack Scenarios:**
+
+| Scenario | Impact |
+|----------|--------|
+| Shared workstation | Next user presses "Back" and sees previous user's eligible elections |
+| Proxy cache (misconfigured) | Corporate proxy caches authenticated pages, serves to wrong users |
+| Browser forensics | Attacker with disk access extracts cached election data from browser cache |
+| Mobile device theft | Thief accesses cached voting pages without authentication |
+
+**Demonstration that Data is Unclassified:**
+
+The absence of cache-control headers demonstrates that the data displayed on these pages has not been assigned a protection level that would mandate cache prevention. A formal classification would specify:
+
+- **CRITICAL data:** Never cached (not applicable here, as crypto material is not displayed)
+- **SENSITIVE data:** `Cache-Control: no-store, no-cache`
+- **INTERNAL data:** `Cache-Control: no-store, no-cache` (same as SENSITIVE for authenticated pages)
+- **PUBLIC data:** Normal caching allowed
+
+**Remediation:**
+
+**Immediate: Add after_request handler for cache headers**
+
+```python
+# pages.py
+
+@APP.after_request
+async def set_security_headers(response):
+ """
+ Add security headers to all responses.
+ Cache-Control prevents sensitive data from being cached.
+ """
+ # Apply to all HTML responses (authenticated pages)
+ if response.content_type and 'text/html' in response.content_type:
+ response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
+ response.headers['Pragma'] = 'no-cache'
+ response.headers['Expires'] = '0'
+
+ return response
+```
+
+**Short-term: Add cache meta tags to templates (defense-in-depth)**
+
+```html
+<!-- templates/header.ezt -->
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Apache STeVe: [title]</title>
+
+ <!-- Prevent caching of sensitive election data -->
+ <meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate, max-age=0">
+ <meta http-equiv="Pragma" content="no-cache">
+ <meta http-equiv="Expires" content="0">
+```
+
+**Long-term: Implement classification-aware caching policy**
+
+```python
+# data_classification.py (extend)
+
+class DataClassification:
+ # ... existing code ...
+
+ CACHE_POLICIES = {
+ CRITICAL: 'no-store, no-cache, must-revalidate, max-age=0',
+ SENSITIVE: 'no-store, no-cache, must-revalidate, max-age=0',
+ INTERNAL: 'no-store, no-cache, must-revalidate, max-age=0',
+ PUBLIC: 'public, max-age=3600',
+ }
+
+ @classmethod
+ def get_cache_policy(cls, classification):
+ """Return appropriate Cache-Control header for a classification level."""
+ return cls.CACHE_POLICIES.get(classification, cls.CACHE_POLICIES[cls.INTERNAL])
+
+# pages.py
+@APP.after_request
+async def set_security_headers(response):
+ if response.content_type and 'text/html' in response.content_type:
+ # Authenticated pages contain at least INTERNAL data
+ cache_policy = DataClassification.get_cache_policy(
+ DataClassification.INTERNAL
+ )
+ response.headers['Cache-Control'] = cache_policy
+ response.headers['Pragma'] = 'no-cache'
+ response.headers['Expires'] = '0'
+ return response
+```
+
+---
+
+### ASVS-1411-MED-004: State-Changing Election Operations via GET Method Expose Identifiers in Browser History and Logs
+
+**Severity:** MEDIUM
+
+**Location:**
+- **File:** `v3/server/pages.py`
+ - **Function:** `do_open_endpoint()` — line ~439
+ - **Function:** `do_close_endpoint()` — line ~457
+- **File:** `v3/server/templates/manage.ezt` — line ~289 (JavaScript invocation)
+
+**Related Functions Checked:**
+- `election.open()` — `election.py` line 72
+- `election.close()` — `election.py` line 84
+- `load_election` decorator
+
+**Vulnerable Code:**
+
+```python
+@APP.get('/do-open/<eid>') # GET for state-changing operation
+@asfquart.auth.require({R.committer})
+@load_election
+async def do_open_endpoint(election):
+ result = await basic_info()
+ election.open(pdb) # Irreversible state change
+ return quart.redirect(f'/manage/{election.eid}', code=303)
+```
+
+```python
+@APP.get('/do-close/<eid>') # GET for state-changing operation
+@asfquart.auth.require({R.committer})
+@load_election
+async def do_close_endpoint(election):
+ result = await basic_info()
+ election.close() # Irreversible state change
+ return quart.redirect(f'/manage/{election.eid}', code=303)
+```
+
+**Template Invocation (manage.ezt):**
+
+```javascript
+// Line ~289
+confirmOpen.addEventListener('click', () => {
+ openModal.hide();
+ window.location.href = '/do-open/[eid]'; // GET request with election ID in URL
+});
+```
+
+**Data Flow — Election ID Exposure:**
+
+```
+User clicks "Open Election" button
+ ↓
+JavaScript navigates to /do-open/<eid> via GET
+ ↓
+Election ID appears in:
+ • Browser address bar
+ • Browser history (persistent)
+ • Server access logs (GET /do-open/abc1234567)
+ • Proxy/CDN logs
+ • Referer header (if redirect target loads external resources)
+ ↓
+Election ID persisted in multiple uncontrolled locations
+```
+
+**Proof of Concept:**
+
+1. As an election owner, navigate to `/manage/abc1234567`
+2. Click "Open Election" button
+3. Observe browser address bar: `https://vote.example.org/do-open/abc1234567`
+4. Check browser history (Ctrl+H): Entry shows `/do-open/abc1234567`
+5. Check server access logs:
+ ```
+ 192.168.1.100 - - [22/Apr/2026:14:23:45 +0000] "GET /do-open/abc1234567 HTTP/1.1" 303 0
+ ```
+
+**Impact:**
+
+Election IDs are designed to be unpredictable (10-character hex, schema `CHECK(length(eid) = 10)`) to prevent enumeration attacks. Using GET for state-changing operations undermines this protection by causing election IDs to appear in:
+
+1. **Browser History:**
+ - Persisted on user's device indefinitely
+ - Visible to anyone with device access (shared workstations, device theft)
+ - Survives session logout
+ - Synced to cloud in browsers with history sync enabled
+
+2. **Server Access Logs:**
+ - `GET /do-open/abc1234567` logged with full URL
+ - Typically retained for extended periods (30-90 days or longer)
+ - Accessible to operations teams who may not need election access
+ - May be aggregated in SIEM systems with broader access
+
+3. **Proxy/CDN Logs:**
+ - If any intermediate proxy is present (corporate proxy, CDN, load balancer)
+ - Logs may have different retention policies than application logs
+
+4. **Referer Headers:**
+ - If the redirect target (`/manage/<eid>`) loads external resources (CDN, analytics)
+ - `Referer: https://vote.example.org/do-open/abc1234567` sent to third parties
+
+**Compounding Factor — Irreversible Operations:**
+
+These endpoints perform **irreversible state changes**:
+
+- **`do_open()`:** Generates cryptographic material (`opened_key`), transitions election to open state
+- **`do_close()`:** Transitions election to closed state, enables tallying
+
+The combination of:
+1. Sensitive operation (state change)
+2. Unpredictable identifier (election ID)
+3. GET method (causes logging)
+
+...creates a security gap where the data exposure is compounded by the action's sensitivity.
+
+**Demonstration that Election IDs are Unclassified:**
+
+The use of GET for these operations demonstrates that election IDs have not been assigned a protection level with handling rules. A formal classification would specify:
+
+- **Election IDs:** INTERNAL classification
+- **Handling rule:** Must not appear in URLs for state-changing operations
+- **Technical control:** POST-only for all state changes
+
+**Remediation:**
+
+**Immediate: Convert to POST method**
+
+```python
+# pages.py
+
+@APP.post('/do-open/<eid>') # Changed from GET to POST
+@asfquart.auth.require({R.committer})
+@load_election
+async def do_open_endpoint(election):
+ result = await basic_info()
+
+ # Verify CSRF token (should already be present via asfquart.auth)
+ form = await quart.request.form
+ # ... CSRF validation ...
+
+ election.open(pdb)
+ return quart.redirect(f'/manage/{election.eid}', code=303)
+
+@APP.post('/do-close/<eid>') # Changed from GET to POST
+@asfquart.auth.require({R.committer})
+@load_election
+async def do_close_endpoint(election):
+ result = await basic_info()
+
+ form = await quart.request.form
+ # ... CSRF validation ...
+
+ election.close()
+ return quart.redirect(f'/manage/{election.eid}', code=303)
+```
+
+**Update template to use form submission:**
+
+```javascript
+// manage.ezt (line ~289)
+
+confirmOpen.addEventListener('click', () => {
+ openModal.hide();
+
+ // Create and submit form instead of navigation
+ const form = document.createElement('form');
+ form.method = 'POST';
+ form.action = '/do-open/[eid]';
+
+ // Add CSRF token
+ const csrfInput = document.createElement('input');
+ csrfInput.type = 'hidden';
+ csrfInput.name = 'csrf_token';
+ csrfInput.value = '[csrf_token]'; // From template context
+ form.appendChild(csrfInput);
+
+ document.body.appendChild(form);
+ form.submit();
+});
+
+// Similar change for confirmClose
+```
+
+**Short-term: Add audit logging (not access logging) for state changes**
+
+```python
+# election.py
+
+def open(self, pdb):
+ """Open this election for voting."""
+ assert self.is_editable(), 'Election is not editable'
+
+ # Generate cryptographic material
+ opened_key = crypto.create_opened_key()
+
+ # Audit log (structured, not access log)
+ _LOGGER.info(
+ 'Election opened: election_id=%s, owner_pid=%s',
+ self.eid[:4] + '***', # Partial ID only
+ self.owner_pid
+ )
+
+ # ... rest of implementation ...
+```
+
+**Long-term: Implement classification-aware routing policy**
+
+```python
+# data_classification.py (extend)
+
+class DataClassification:
+ # ... existing code ...
+
+ @classmethod
+ def validate_route(cls, method, path, classification):
+ """
+ Validate that HTTP method is appropriate for data classification.
+
+ Rules:
+ - CRITICAL/SENSITIVE/INTERNAL identifiers: POST only for state changes
+ - PUBLIC identifiers: GET allowed
+ """
+ if classification in (cls.CRITICAL, cls.SENSITIVE, cls.INTERNAL):
+ if method == 'GET' and cls._is_state_changing(path):
+ raise SecurityError(
+ f'GET method not allowed for {classification} data '
+ f'in state-changing operation: {path}'
+ )
+
+ @staticmethod
+ def _is_state_changing(path):
+ """Determine if a path represents a state-changing operation."""
+ state_changing_patterns = [
+ '/do-open/', '/do-close/', '/do-add-', '/do-edit-', '/do-delete-'
+ ]
+ return any(pattern in path for pattern in state_changing_patterns)
+```
+
+---
+
+## Positive Security Patterns
+
+The following security controls demonstrate good practices and should be maintained:
+
+### 1. Explicit Field Selection in `get_metadata()`
+
+**Location:** `v3/steve/election.py` — lines 146-157
+
+**Pattern:**
+```python
+def get_metadata(self):
+ "Return basic metadata about this Election."
+ md = self._all_metadata()
+ # NOTE: do not return the SALT or OPENED_KEY
+ return edict(
+ eid=md.eid, title=md.title, owner_pid=md.owner_pid,
+ authz=md.authz, closed=md.closed,
+ open_at=md.open_at, close_at=md.close_at,
+ state=self._compute_state(md),
+ )
+```
+
+**Why This is Good:**
+- Constructs return dictionary with only non-sensitive fields
+- Explicit allowlist approach (safer than blocklist)
+- Code comment documents the security decision
+- Forces caller to explicitly request sensitive data via separate methods
+
+### 2. Vote Encryption Before Storage
+
+**Location:** `v3/steve/election.py` — lines 239-253
+
+**Pattern:**
+```python
+def add_vote(self, uid, iid, votestring):
+ "Add a vote for the given user and issue."
+ assert self.is_open(), 'Election is not open'
+
+ # ... validation ...
+
+ # Encrypt vote BEFORE database insertion
+ vote_token, ciphertext = crypto.create_vote(
+ opened_key, uid, iid, votestring
+ )
+
+ # Only encrypted data reaches database
+ self.db.q_add_vote.perform(
+ iid, vote_token, ciphertext,
+ )
+```
+
+**Why This is Good:**
+- Plaintext vote strings never reach the database
+- Encryption is mandatory (not optional)
+- Function signature ensures caller cannot bypass encryption
+- Even database administrators cannot read vote content
+
+### 3. State Machine Enforcement
+
+**Location:** `v3/steve/election.py` — various functions
+
+**Pattern:**
+```python
+def open(self, pdb):
+ """Open this election for voting."""
+ assert self.is_editable(), 'Election is not editable'
+ # ... only proceeds if state is correct
+
+def add_vote(self, uid, iid, votestring):
+ """Add a vote for the given user and issue."""
+ assert self.is_open(), 'Election is not open'
+ # ... only proceeds if state is correct
+
+def tally_issue(self, iid):
+ """Tally the votes for a specific issue."""
+ assert self.is_closed(), 'Election is not closed'
+ # ... only proceeds if state is correct
+```
+
+**Why This is Good:**
+- Assert-based state checks prevent operations in wrong states
+- Prevents premature tallying (ballot secrecy protection)
+- Prevents voting after close (integrity protection)
+- State transitions are explicit and auditable
+
+### 4. Vote Shuffling in `tally_issue()`
+
+**Location:** `v3/steve/election.py` — lines 255-310
+
+**Pattern:**
+```python
+def tally_issue(self, iid):
+ """Tally the votes for a specific issue."""
+ assert self.is_closed(), 'Election is not closed'
+
+ # ... decrypt votes ...
+
+ # Shuffle to prevent database-order correlation
+ votes = crypto.shuffle(votes)
+
+ return votes
+```
+
+**Why This is Good:**
+- Randomizes decrypted vote order before returning
+- Prevents correlation analysis based on database insertion order
+- Even if attacker knows when a user voted, cannot identify their vote in results
+- Simple but effective anonymization technique
+
+### 5. Per-Voter Salts
+
+**Location:** `v3/steve/election.py` — lines 121-139, schema `mayvote.salt`
+
+**Pattern:**
+```python
+def add_salts(self, pdb):
+ "Add salt values for all persons in the authorization list."
+ assert self.is_editable(), 'Election is not editable'
+
+ db = self.db
+ db.cursor.execute('BEGIN TRANSACTION')
+
+ for pid, in db.q_pids.fetchall():
+ salt = crypto.create_salt()
+ db.q_add_salt.perform(self.eid, pid, salt,)
+
+ db.cursor.execute('COMMIT')
+```
+
+**Why This is Good:**
+- Each voter gets unique cryptographic salt
+- Salts are generated atomically within transaction
+- Even with database access, vote tokens cannot be correlated across issues without per-voter material
+- Strengthens ballot secrecy against database compromise
+
+### 6. Transaction-Based Salt Generation
+
+**Location:** `v3/steve/election.py` — lines 121-139
+
+**Pattern:**
+```python
+db.cursor.execute('BEGIN TRANSACTION')
+
+for pid, in db.q_pids.fetchall():
+ salt = crypto.create_salt()
+ db.q_add_salt.perform(self.eid, pid, salt,)
+
+db.cursor.execute('COMMIT')
+```
+
+**Why This is Good:**
+- Ensures atomicity — either all salts are generated or none
+- Prevents partial salt assignment if operation is interrupted
+- Maintains database consistency
+- Follows ACID principles for security-critical operations
+
+### 7. Logging Excludes Vote Content
+
+**Location:** Throughout `v3/steve/election.py` and `v3/server/pages.py`
+
+**Pattern:**
+```python
+_LOGGER.info('Vote added for user[U:%s] issue[I:%s]', uid, iid)
+# Never logs votestring itself
+
+_LOGGER.info('Election closed: eid=%s', self.eid)
+# Logs election ID but not cryptographic material
+```
+
+**Why This is Good:**
+- All `_LOGGER.info()` calls in vote submission log voter ID and issue ID but never include vote content
+- Demonstrates awareness of sensitive data even without formal classification
+- Provides audit trail without compromising ballot secrecy
+
+---
+
+## Architecture Observations
+
+### 1. Data Protection Boundary
+
+**Current State:**
+The system has an implicit two-tier data model:
+- **Public metadata:** titles, dates, state, candidate lists
+- **Cryptographic material:** salts, keys, tokens, ciphertext
+
+**Observation:**
+This boundary is enforced by convention (individual function implementations) rather than by architecture (separate data access layers or classification-aware middleware).
+
+**Implications:**
+- Refactoring may accidentally violate the boundary
+- New developers must learn conventions through code reading
+- No automated verification that boundary is maintained
+
+**Recommendation:**
+Implement architectural enforcement via:
+- Separate data access layer with classification-aware queries
+- Middleware that validates data classification before template rendering
+- Type system annotations for classified data
+
+### 2. Comment-Driven Security
+
+**Current State:**
+Multiple critical security decisions are documented only in code comments:
+- `### check authz` (pages.py)
+- `# NEVER return issue.salt` (election.py line 163)
+- `# NOTE: do not return the SALT or OPENED_KEY` (election.py line 148)
+
+**Observation:**
+These comments indicate developer awareness of sensitive data, but comments:
+- Are not enforced by the runtime
+- Can become outdated without detection
+- Rely on code review to maintain
+
+**Implications:**
+- Security depends on developer discipline
+- Automated tools cannot verify comment-based requirements
+- Comments may be removed during refactoring
+
+**Recommendation:**
+Convert comment-based security decisions to:
+- Runtime assertions
+- Type system constraints
+- Automated tests that verify the security property
+
+### 3. Future Risk in `tally_issue()`
+
+**Current State:**
+The function contains a commented-out line:
+```python
+#md = self._all_metadata()
+# TBD: we need a param to 'spy' on Open elections
+```
+
+**Observation:**
+This suggests planned functionality to view partial results during open elections. If implemented, this would fundamentally undermine ballot secrecy by allowing:
+- Correlation of vote timing with vote content
+- Influence on later voters based on partial results
+- Coercion ("prove you voted for X by showing partial results")
+
+**Implications:**
+- The TODO indicates this feature is under consideration
+- Without formal data classification, there is no documented policy forbidding this
+- Future developer may implement this without understanding ballot secrecy implications
+
+**Recommendation:**
+- **Immediate:** Add comment explaining why this is forbidden: `# SECURITY: Viewing open election results violates ballot secrecy. DO NOT IMPLEMENT.`
+- **Short-term:** Document in data classification policy that decrypted vote content is CRITICAL and must never be accessible while election is open
+- **Long-term:** Add automated test that verifies `tally_issue()` raises exception if called on open election
+
+### 4. PersonDB Email Exposure
+
+**Current State:**
+The `get_person()` API returns `(name, email)` as a tuple:
+```python
+def get_person(self, pid):
+ "Return the Person with the given PID."
+ # NOTE: NEVER return the salt value.
+ for name, email in self.db.q_get_person.fetchall():
+ return name, email
+```
+
+**Observation:**
+Most callers only use `name`:
+```python
+name, _ = pdb.get_person(uid) # Email discarded
+```
+
+**Implications:**
+- Unnecessary PII exposure (email is more sensitive than name)
+- Violates principle of least privilege
+- A classification-aware API would separate these into different access levels
+
+**Recommendation:**
+```python
+def get_person_name(self, pid):
+ """Return only the name for the given PID (least privilege)."""
+ for name, in self.db.q_get_person_name.fetchall():
+ return name
+
+def get_person_email(self, pid):
+ """Return the email for the given PID (requires justification)."""
+ for email, in self.db.q_get_person_email.fetchall():
+ return email
+
+def get_person_full(self, pid):
+ """Return full person record including PII (requires justification)."""
+ for name, email in self.db.q_get_person.fetchall():
+ return name, email
+```
+
+---
+
+## Recommendations Summary
+
+### Immediate Actions (Week 1)
+
+| Priority | Recommendation | Findings Addressed | Effort |
+|----------|---------------|-------------------|--------|
+| 1 | **Remove all `print()` statements** from `do_add_issue_endpoint()` and `do_edit_issue_endpoint()` | ASVS-1411-MED-002 | 15 min |
+| 2 | **Add Cache-Control headers** via `@APP.after_request` handler | ASVS-1411-MED-003 | 30 min |
+| 3 | **Convert `/do-open/` and `/do-close/` to POST** methods | ASVS-1411-MED-004 | 1 hour |
+| 4 | **Add security comment** to `tally_issue()` TODO explaining ballot secrecy risk | Architecture Observation 3 | 5 min |
+
+### Short-Term Actions (Month 1)
+
+| Priority | Recommendation | Findings Addressed | Effort |
+|----------|---------------|-------------------|--------|
+| 5 | **Create formal data classification document** defining CRITICAL, SENSITIVE, INTERNAL, PUBLIC tiers with handling rules | ASVS-1411-HIGH-001 | 2 days |
+| 6 | **Implement `sanitize_for_template()` defense-in-depth filter** at template rendering boundary | ASVS-1411-HIGH-001 | 1 day |
+| 7 | **Add classification verification tests** for `get_metadata()`, `get_issue()`, template sanitization | ASVS-1411-HIGH-001 | 1 day |
+| 8 | **Implement structured logging with field-level sensitivity filtering** | ASVS-1411-MED-002 | 2 days |
+| 9 | **Add cache meta tags** to `header.ezt` (defense-in-depth) | ASVS-1411-MED-003 | 15 min |
+| 10 | **Refactor `get_person()` into separate access-level methods** (`get_person_name()`, `get_person_email()`) | Architecture Observation 4 | 1 day |
+
+### Long-Term Actions (Quarter 1)
+
+| Priority | Recommendation | Findings Addressed | Effort |
+|----------|---------------|-------------------|--------|
+| 11 | **Implement classification-aware data access layer** with automatic field filtering based on classification | ASVS-1411-HIGH-001 | 1 week |
+| 12 | **Add runtime classification validation** in `postprocess_election()` and other data processing functions | ASVS-1411-HIGH-001 | 3 days |
+| 13 | **Implement classification-aware routing policy** that validates HTTP method based on data sensitivity | ASVS-1411-MED-004 | 3 days |
+| 14 | **Add automated security tests** that verify no CRITICAL fields ever reach templates | ASVS-1411-HIGH-001 | 2 days |
+| 15 | **Document log sanitization policy** explicitly listing fields that must never appear in logs | ASVS-1411-HIGH-001, ASVS-1411-MED-002 | 1 day |
+| 16 | **Conduct privacy impact assessment** to identify any additional PII requiring classification | ASVS-1411-HIGH-001 | 1 week |
+
+---
+
+## Appendix: Files Analyzed
+
+<details>
+<summary>Click to expand full file list (20 files)</summary>
+
+1. `v3/schema.sql` (relevance: 10)
+2. `v3/server/pages.py` (relevance: 10)
+3. `v3/server/templates/about.ezt` (relevance: 10)
+4. `v3/server/templates/admin.ezt` (relevance: 10)
+5. `v3/server/templates/e_bad_eid.ezt` (relevance: 10)
+6. `v3/server/templates/e_bad_iid.ezt` (relevance: 10)
+7. `v3/server/templates/e_bad_pid.ezt` (relevance: 10)
+8. `v3/server/templates/flashes.ezt` (relevance: 10)
+9. `v3/server/templates/footer.ezt` (relevance: 10)
+10. `v3/server/templates/header.ezt` (relevance: 10)
+11. `v3/server/templates/home.ezt` (relevance: 10)
+12. `v3/server/templates/manage-stv.ezt` (relevance: 10)
+13. `v3/server/templates/manage.ezt` (relevance: 10)
+14. `v3/server/templates/privacy.ezt` (relevance: 10)
+15. `v3/server/templates/profile.ezt` (relevance: 10)
+16. `v3/server/templates/settings.ezt` (relevance: 10)
+17. `v3/server/templates/vote-on.ezt` (relevance: 10)
+18. `v3/server/templates/voter.ezt` (relevance: 10)
+19. `v3/steve/election.py` (relevance: 10)
+20. `v3/steve/persondb.py` (relevance: 10)
+
+</details>
+
+---
+
+**End of Report**
\ No newline at end of file
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org