Neue Skills, Referenzen & OpenWiki-Doku integriert
Umfangreiche Erweiterung der Skill-Bibliothek: Neue Skills für Humanisierung (Englisch/PT-BR), Design-Validierung, AI-SEO und Coolify-Deployment inkl. Regelwerke, Presets, Pattern-Referenzen, Testfälle und Automatisierungsskripte. Zusätzliche Skills für Revenue-Centric Design, Pier Cloud, OKF, Lebenslauf- und LinkedIn-Optimierung sowie zahlreiche Referenzdateien, Checklisten und YAML/JSON/Markdown-Templates. Einführung einer vollständigen OpenWiki-Dokumentation mit Architektur-, Domain- und Workflow-Beschreibungen, zentralem Index und automatisierten Updates. Modularer Aufbau, restriktive Lizenzen und umfassende Qualitäts- und Evaluationsmechanismen für alle neuen Inhalte.
This commit is contained in:
201
.github/skills/security-specialist/references/finding-format.md
vendored
Normal file
201
.github/skills/security-specialist/references/finding-format.md
vendored
Normal file
@@ -0,0 +1,201 @@
|
||||
# Finding Format Specification
|
||||
|
||||
Este documento define a estrutura canônica de um security finding. Todo finding produzido pelo security-specialist DEVE conformar a este schema.
|
||||
|
||||
---
|
||||
|
||||
## Formato Simples (SQLite — uso interno)
|
||||
|
||||
Para persistência no scan.db e workflows modulares (discovery, triage, diff-review):
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `id` | UUID v4 | yes | Identificador único |
|
||||
| `scan_id` | UUID v4 | yes | FK para scan session parent |
|
||||
| `title` | string | yes | Nome da vulnerabilidade (≤ 80 chars) |
|
||||
| `severity` | enum | yes | `critical`, `high`, `medium`, `low`, `info` |
|
||||
| `category` | enum | yes | `injection`, `xss`, `auth`, `crypto`, `exposure`, `config`, `dependency`, `logic`, `other` |
|
||||
| `status` | enum | yes | `open`, `fixed`, `false-positive`, `accepted-risk`, `tracked` |
|
||||
| `file_path` | string | yes | Repo-relative path |
|
||||
| `line_number` | integer | yes | Line onde a vulnerabilidade origina |
|
||||
| `description` | string | yes | 2–4 frases explicando o quê e por quê importa |
|
||||
| `evidence` | string | yes | Código, data flow trace, ou PoC |
|
||||
| `remediation` | string | no | Fix sugerido |
|
||||
| `tracking_url` | string | no | URL do issue tracker externo |
|
||||
| `notes` | string | no | Notas de triage |
|
||||
| `created_at` | string | yes | ISO 8601 timestamp |
|
||||
|
||||
---
|
||||
|
||||
## Formato Estruturado (JSON — output de full-scan pipeline)
|
||||
|
||||
Para findings que passam pela pipeline completa de 6 fases (full-scan), use o formato rico definido em `report-schema.json`. Este formato é **obrigatório** para o output `findings.json` do full-scan.
|
||||
|
||||
### Campos do Formato Estruturado
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `verdict` | `confirmed` ou `rejected` |
|
||||
| `title` | Título conciso e padronizado |
|
||||
| `description` | Explicação completa com detalhes de reprodução |
|
||||
| `root_cause` | Template: `[function] em [file] não [ação], permitindo [consequência]` |
|
||||
| `intended_behavior` | O que o dev tentou construir (lógica não-vulnerável) |
|
||||
| `trace` | Array sequencial: `entrypoint` → `propagation`* → `sink` |
|
||||
| `conditions` | Pré-requisitos factuais para exploração |
|
||||
| `execution` | Perspectiva do atacante, payloads, instruções, resultado esperado |
|
||||
| `remediation` | Estratégia + code_changes opcionais |
|
||||
| `severity` | Likelihood × Impact, cada com score + reason |
|
||||
| `confidence` | Score (low/medium/high) + reason |
|
||||
|
||||
### Trace
|
||||
|
||||
Cada step do trace contém:
|
||||
```json
|
||||
{
|
||||
"kind": "entrypoint|propagation|sink",
|
||||
"file": "src/routes/users.js",
|
||||
"line": 42,
|
||||
"scope": "searchUsers",
|
||||
"description": "User input from query param 'q' enters the handler"
|
||||
}
|
||||
```
|
||||
|
||||
**Regras:**
|
||||
- Mínimo 2 steps (entrypoint + sink)
|
||||
- Primeiro step DEVE ser `kind: "entrypoint"`
|
||||
- Último step DEVE ser `kind: "sink"`
|
||||
- File paths relativos à raiz do repositório
|
||||
- Scope é function/method name sem parênteses
|
||||
|
||||
### Conditions
|
||||
|
||||
Pré-requisitos factuais. Array vazio = explorável por default.
|
||||
```json
|
||||
{
|
||||
"kind": "authentication_level",
|
||||
"description": "Requires authenticated session with any role"
|
||||
}
|
||||
```
|
||||
|
||||
Kinds válidos: `authentication_level`, `authorization_role`, `user_interaction`, `system_configuration`, `network_routing`, `environmental_dependency`, `data_state`, `timing_dependency`, `third_party_dependency`
|
||||
|
||||
### Execution
|
||||
|
||||
```json
|
||||
{
|
||||
"attacker_perspective": "Authenticated user with basic role",
|
||||
"payloads": ["GET /api/search?q=' UNION SELECT password FROM users--"],
|
||||
"instructions": [
|
||||
"Login with any valid account",
|
||||
"Navigate to search endpoint",
|
||||
"Inject SQL via query parameter"
|
||||
],
|
||||
"expected_result": "Response contains all user password hashes"
|
||||
}
|
||||
```
|
||||
|
||||
### Confidence
|
||||
|
||||
```json
|
||||
{
|
||||
"score": "high",
|
||||
"reason": "Full trace verified against source. All steps readable and confirmed."
|
||||
}
|
||||
```
|
||||
|
||||
- **high**: Trace completo verificado, exploit testável
|
||||
- **medium**: Trace parcialmente verificado, algumas assumptions
|
||||
- **low**: Static analysis only, complex routing, missing files
|
||||
|
||||
---
|
||||
|
||||
## Quando Usar Qual Formato
|
||||
|
||||
| Workflow | Formato |
|
||||
|----------|---------|
|
||||
| `full-scan` (pipeline 6 fases) | **Estruturado** (findings.json validado contra schema) |
|
||||
| `discovery`, `diff-review`, `triage` | **Simples** (SQLite) |
|
||||
| `pentest` | **Simples** (SQLite) + evidence expandida |
|
||||
| `reporting` (HTML final) | Ambos — HTML renderiza de qualquer fonte |
|
||||
|
||||
---
|
||||
|
||||
## Validação
|
||||
|
||||
Para o formato estruturado, valide com:
|
||||
```bash
|
||||
node scripts/validate-findings.cjs .security/scans/<timestamp>/findings.json
|
||||
```
|
||||
|
||||
O validador checa: required fields, enum values, structural constraints, `additionalProperties`, e semantic rules (trace starts at entrypoint, ends at sink).
|
||||
|
||||
---
|
||||
|
||||
## Exemplo: Formato Estruturado Completo
|
||||
|
||||
```json
|
||||
{
|
||||
"verdict": "confirmed",
|
||||
"title": "SQL Injection in user search endpoint",
|
||||
"description": "User-supplied search parameter is concatenated directly into SQL query. Authenticated user can extract arbitrary data including credentials.",
|
||||
"root_cause": "searchUsers in src/routes/users.js does not parameterize user input, allowing arbitrary SQL execution.",
|
||||
"intended_behavior": "Search should filter users by name using parameterized queries, returning only matching records the caller is authorized to see.",
|
||||
"trace": [
|
||||
{
|
||||
"kind": "entrypoint",
|
||||
"file": "src/routes/users.js",
|
||||
"line": 35,
|
||||
"scope": "searchUsers",
|
||||
"description": "User input from req.query.search enters handler"
|
||||
},
|
||||
{
|
||||
"kind": "propagation",
|
||||
"file": "src/routes/users.js",
|
||||
"line": 42,
|
||||
"scope": "searchUsers",
|
||||
"description": "Input concatenated into SQL string template without escaping"
|
||||
},
|
||||
{
|
||||
"kind": "sink",
|
||||
"file": "src/routes/users.js",
|
||||
"line": 43,
|
||||
"scope": "searchUsers",
|
||||
"description": "Concatenated string passed to db.raw() for execution"
|
||||
}
|
||||
],
|
||||
"conditions": [
|
||||
{
|
||||
"kind": "authentication_level",
|
||||
"description": "Requires valid session (any role)"
|
||||
}
|
||||
],
|
||||
"execution": {
|
||||
"attacker_perspective": "Authenticated user with basic role",
|
||||
"payloads": ["GET /api/users?search=' UNION SELECT password FROM users--"],
|
||||
"instructions": [
|
||||
"Login with any valid account",
|
||||
"Send GET request to /api/users with crafted search parameter",
|
||||
"Observe response containing all password hashes"
|
||||
],
|
||||
"expected_result": "Response body contains password hashes for all users in database"
|
||||
},
|
||||
"remediation": {
|
||||
"strategy": "Use parameterized queries via the ORM's query builder instead of string concatenation.",
|
||||
"code_changes": [
|
||||
{
|
||||
"file_name": "src/routes/users.js",
|
||||
"fixed_code": "const results = await db('users').where('name', 'like', `%${search}%`);"
|
||||
}
|
||||
]
|
||||
},
|
||||
"severity": {
|
||||
"likelihood": { "score": "high", "reason": "Any authenticated user can exploit. No special tools needed." },
|
||||
"impact": { "score": "critical", "reason": "Full database read access including credentials and PII." },
|
||||
"overall_severity": "critical"
|
||||
},
|
||||
"confidence": {
|
||||
"score": "high",
|
||||
"reason": "Full trace verified. db.raw() confirmed at line 43. No parameterization in path."
|
||||
}
|
||||
}
|
||||
```
|
||||
284
.github/skills/security-specialist/references/report-format.md
vendored
Normal file
284
.github/skills/security-specialist/references/report-format.md
vendored
Normal file
@@ -0,0 +1,284 @@
|
||||
# Report Format Specification
|
||||
|
||||
The final report is a **self-contained HTML file** (`security-report.html`). It opens in any browser, uses no external dependencies, and includes interactive features (collapsible sections, filters, color-coded severity).
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
Single HTML file with embedded CSS and JS. No external CDN, no build step. The report must work offline when opened with `file://`.
|
||||
|
||||
---
|
||||
|
||||
## Severity Color System
|
||||
|
||||
| Severity | Color | Badge HTML |
|
||||
|----------|-------|-----------|
|
||||
| Critical | `#dc2626` (red-600) | `<span class="badge badge-critical">CRITICAL</span>` |
|
||||
| High | `#ea580c` (orange-600) | `<span class="badge badge-high">HIGH</span>` |
|
||||
| Medium | `#ca8a04` (yellow-600) | `<span class="badge badge-medium">MEDIUM</span>` |
|
||||
| Low | `#16a34a` (green-600) | `<span class="badge badge-low">LOW</span>` |
|
||||
| Info | `#6b7280` (gray-500) | `<span class="badge badge-info">INFO</span>` |
|
||||
|
||||
---
|
||||
|
||||
## HTML Template
|
||||
|
||||
Generate the report using this structure. Replace `{{placeholders}}` with actual data.
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Security Audit — {{repo-name}}</title>
|
||||
<style>
|
||||
:root {
|
||||
--critical: #dc2626; --high: #ea580c; --medium: #ca8a04;
|
||||
--low: #16a34a; --info: #6b7280; --pass: #16a34a; --fail: #dc2626;
|
||||
--bg: #0f172a; --surface: #1e293b; --surface-2: #334155;
|
||||
--text: #f1f5f9; --text-muted: #94a3b8; --border: #475569;
|
||||
--code-bg: #0f172a; --accent: #3b82f6;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: 'Inter', -apple-system, sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; padding: 2rem; max-width: 1200px; margin: 0 auto; }
|
||||
h1 { font-size: 1.75rem; margin-bottom: 0.25rem; }
|
||||
h2 { font-size: 1.35rem; margin: 2.5rem 0 1rem; padding-bottom: 0.5rem; border-bottom: 1px solid var(--border); }
|
||||
h3 { font-size: 1.1rem; margin: 1.5rem 0 0.5rem; }
|
||||
p, li { color: var(--text-muted); }
|
||||
a { color: var(--accent); }
|
||||
code { background: var(--code-bg); border: 1px solid var(--border); padding: 0.15em 0.4em; border-radius: 4px; font-size: 0.85em; }
|
||||
pre { background: var(--code-bg); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; overflow-x: auto; margin: 0.75rem 0; }
|
||||
pre code { border: none; padding: 0; background: none; }
|
||||
|
||||
/* Badges */
|
||||
.badge { display: inline-block; padding: 0.2em 0.6em; border-radius: 4px; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: white; }
|
||||
.badge-critical { background: var(--critical); }
|
||||
.badge-high { background: var(--high); }
|
||||
.badge-medium { background: var(--medium); }
|
||||
.badge-low { background: var(--low); }
|
||||
.badge-info { background: var(--info); }
|
||||
.badge-pass { background: var(--pass); }
|
||||
.badge-fail { background: var(--fail); }
|
||||
|
||||
/* Summary cards */
|
||||
.summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 1rem; margin: 1rem 0; }
|
||||
.summary-card { background: var(--surface); border-radius: 8px; padding: 1rem; text-align: center; border-left: 4px solid var(--border); }
|
||||
.summary-card .count { font-size: 2rem; font-weight: 700; }
|
||||
.summary-card .label { font-size: 0.8rem; color: var(--text-muted); text-transform: uppercase; }
|
||||
.summary-card.critical { border-left-color: var(--critical); }
|
||||
.summary-card.critical .count { color: var(--critical); }
|
||||
.summary-card.high { border-left-color: var(--high); }
|
||||
.summary-card.high .count { color: var(--high); }
|
||||
.summary-card.medium { border-left-color: var(--medium); }
|
||||
.summary-card.medium .count { color: var(--medium); }
|
||||
.summary-card.low { border-left-color: var(--low); }
|
||||
.summary-card.low .count { color: var(--low); }
|
||||
.summary-card.info { border-left-color: var(--info); }
|
||||
.summary-card.info .count { color: var(--info); }
|
||||
|
||||
/* Tables */
|
||||
table { width: 100%; border-collapse: collapse; margin: 1rem 0; font-size: 0.9rem; }
|
||||
th, td { padding: 0.6rem 0.8rem; text-align: left; border-bottom: 1px solid var(--border); }
|
||||
th { background: var(--surface); color: var(--text); font-weight: 600; position: sticky; top: 0; }
|
||||
tr:hover { background: var(--surface); }
|
||||
|
||||
/* Finding cards */
|
||||
.finding { background: var(--surface); border-radius: 8px; padding: 1.25rem; margin: 1rem 0; border-left: 4px solid var(--border); }
|
||||
.finding.critical { border-left-color: var(--critical); }
|
||||
.finding.high { border-left-color: var(--high); }
|
||||
.finding.medium { border-left-color: var(--medium); }
|
||||
.finding.low { border-left-color: var(--low); }
|
||||
.finding.info { border-left-color: var(--info); }
|
||||
.finding-header { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; }
|
||||
.finding-title { font-weight: 600; font-size: 1rem; }
|
||||
.finding-meta { font-size: 0.8rem; color: var(--text-muted); margin-bottom: 0.5rem; }
|
||||
|
||||
/* Pentest results */
|
||||
.pentest-item { background: var(--surface); border-radius: 8px; padding: 1rem; margin: 0.75rem 0; }
|
||||
.pentest-item .result-pass { color: var(--pass); font-weight: 700; }
|
||||
.pentest-item .result-fail { color: var(--fail); font-weight: 700; }
|
||||
|
||||
/* Collapsible */
|
||||
details { margin: 0.5rem 0; }
|
||||
details summary { cursor: pointer; padding: 0.5rem; border-radius: 4px; font-weight: 600; }
|
||||
details summary:hover { background: var(--surface-2); }
|
||||
details[open] summary { margin-bottom: 0.5rem; }
|
||||
|
||||
/* CVE table */
|
||||
.cve-exploitable { color: var(--fail); font-weight: 700; }
|
||||
.cve-not-exploitable { color: var(--pass); }
|
||||
|
||||
/* Filters */
|
||||
.filters { display: flex; gap: 0.5rem; flex-wrap: wrap; margin: 1rem 0; }
|
||||
.filter-btn { padding: 0.4em 0.8em; border-radius: 4px; border: 1px solid var(--border); background: var(--surface); color: var(--text-muted); cursor: pointer; font-size: 0.8rem; transition: 0.2s; }
|
||||
.filter-btn:hover, .filter-btn.active { background: var(--accent); color: white; border-color: var(--accent); }
|
||||
|
||||
/* Metadata */
|
||||
.meta-grid { display: grid; grid-template-columns: auto 1fr; gap: 0.25rem 1rem; font-size: 0.9rem; margin: 1rem 0; }
|
||||
.meta-grid dt { color: var(--text-muted); }
|
||||
.meta-grid dd { color: var(--text); }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
body { padding: 1rem; }
|
||||
.summary-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>🛡️ Security Audit Report</h1>
|
||||
<dl class="meta-grid">
|
||||
<dt>Repository</dt><dd>{{repo-name}}</dd>
|
||||
<dt>Date</dt><dd>{{date}}</dd>
|
||||
<dt>Target</dt><dd>{{target-urls}}</dd>
|
||||
<dt>Methodology</dt><dd>SAST + DAST (localhost) + DAST (production) + Pentest</dd>
|
||||
</dl>
|
||||
|
||||
<!-- Section: Summary Cards -->
|
||||
<h2>Resumo</h2>
|
||||
<div class="summary-grid">
|
||||
<div class="summary-card critical"><div class="count">{{critical-count}}</div><div class="label">Critical</div></div>
|
||||
<div class="summary-card high"><div class="count">{{high-count}}</div><div class="label">High</div></div>
|
||||
<div class="summary-card medium"><div class="count">{{medium-count}}</div><div class="label">Medium</div></div>
|
||||
<div class="summary-card low"><div class="count">{{low-count}}</div><div class="label">Low</div></div>
|
||||
<div class="summary-card info"><div class="count">{{info-count}}</div><div class="label">Info</div></div>
|
||||
</div>
|
||||
<p>{{executive-summary-paragraph}}</p>
|
||||
|
||||
<!-- Section: Findings with filters -->
|
||||
<h2>Achados</h2>
|
||||
<div class="filters">
|
||||
<button class="filter-btn active" onclick="filterFindings('all')">Todos</button>
|
||||
<button class="filter-btn" onclick="filterFindings('critical')">Critical</button>
|
||||
<button class="filter-btn" onclick="filterFindings('high')">High</button>
|
||||
<button class="filter-btn" onclick="filterFindings('medium')">Medium</button>
|
||||
<button class="filter-btn" onclick="filterFindings('low')">Low</button>
|
||||
<button class="filter-btn" onclick="filterFindings('info')">Info</button>
|
||||
</div>
|
||||
|
||||
<!-- Repeat this block for each finding -->
|
||||
<div class="finding {{severity}}" data-severity="{{severity}}">
|
||||
<div class="finding-header">
|
||||
<span class="badge badge-{{severity}}">{{SEVERITY}}</span>
|
||||
<span class="finding-title">{{finding-title}}</span>
|
||||
</div>
|
||||
<div class="finding-meta">📁 <code>{{file}}:{{line}}</code></div>
|
||||
<p>{{description}}</p>
|
||||
<details>
|
||||
<summary>Evidência</summary>
|
||||
<pre><code>{{evidence-code}}</code></pre>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Remediação</summary>
|
||||
<p>{{remediation-text}}</p>
|
||||
<pre><code>{{remediation-code}}</code></pre>
|
||||
</details>
|
||||
</div>
|
||||
<!-- End finding block -->
|
||||
|
||||
<!-- Section: CVE Analysis -->
|
||||
<h2>Análise de CVEs × Contexto do Projeto</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>#</th><th>Advisory</th><th>Sev. Genérica</th><th>Precondição</th><th>Presente?</th><th>Sev. Real</th><th>Razão</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Repeat per CVE -->
|
||||
<tr>
|
||||
<td>{{n}}</td>
|
||||
<td><a href="{{advisory-url}}">{{advisory-id}}</a></td>
|
||||
<td><span class="badge badge-{{generic-sev}}">{{generic-sev}}</span></td>
|
||||
<td>{{precondition}}</td>
|
||||
<td class="{{cve-exploitable|cve-not-exploitable}}">{{yes-no}}</td>
|
||||
<td><span class="badge badge-{{real-sev}}">{{real-sev}}</span></td>
|
||||
<td>{{rationale}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Section: Pentest Results -->
|
||||
<h2>Pentest — Testes Ativos</h2>
|
||||
|
||||
<!-- Repeat per test -->
|
||||
<div class="pentest-item">
|
||||
<strong>P{{n}}: {{test-name}}</strong>
|
||||
<span class="{{result-pass|result-fail}}">{{PASS|FAIL}}</span>
|
||||
<details>
|
||||
<summary>Detalhes</summary>
|
||||
<p><strong>Objetivo:</strong> {{objective}}</p>
|
||||
<pre><code>{{command-or-payload}}</code></pre>
|
||||
<p><strong>Resposta:</strong> {{response-summary}}</p>
|
||||
</details>
|
||||
</div>
|
||||
<!-- End pentest block -->
|
||||
|
||||
<!-- Section: Verified Secure -->
|
||||
<h2>Verificado Seguro ✅</h2>
|
||||
<table>
|
||||
<thead><tr><th>Teste</th><th>Resultado</th><th>Evidência</th></tr></thead>
|
||||
<tbody>
|
||||
<!-- Repeat per negative finding -->
|
||||
<tr>
|
||||
<td>{{test-name}}</td>
|
||||
<td><span class="badge badge-pass">PASS</span></td>
|
||||
<td>{{evidence}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Section: Recommendations -->
|
||||
<h2>Remediação Prioritária</h2>
|
||||
<table>
|
||||
<thead><tr><th>#</th><th>Ação</th><th>Esforço</th><th>Impacto</th></tr></thead>
|
||||
<tbody>
|
||||
<!-- Repeat per recommendation -->
|
||||
<tr><td>{{n}}</td><td>{{action}}</td><td>{{effort}}</td><td>{{impact}}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<script>
|
||||
function filterFindings(severity) {
|
||||
document.querySelectorAll('.finding').forEach(el => {
|
||||
el.style.display = (severity === 'all' || el.dataset.severity === severity) ? '' : 'none';
|
||||
});
|
||||
document.querySelectorAll('.filter-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.textContent.toLowerCase() === severity || (severity === 'all' && btn.textContent === 'Todos'));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<footer style="margin-top:4rem;padding-top:1.5rem;border-top:1px solid var(--border);text-align:center;font-size:0.8rem;color:var(--text-muted);">
|
||||
Generated by <strong>security-specialist</strong> skill by <a href="https://github.com/fabricioctelles/skills" style="color:var(--accent);">github.com/fabricioctelles/skills</a>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Generation Rules
|
||||
|
||||
1. **Output a single `.html` file** — not markdown. Name it `security-report.html` in the repo root.
|
||||
2. **Replace all `{{placeholders}}`** with actual data from the scan.
|
||||
3. **Repeat blocks** as indicated by comments (`<!-- Repeat per finding -->`, etc.).
|
||||
4. **Sort findings** by severity descending (critical first), then alphabetically.
|
||||
5. **Collapsible evidence/remediation** — keeps the report scannable without hiding info.
|
||||
6. **Filter buttons** — JS filters findings by severity interactively.
|
||||
7. **Code in evidence** — use `<pre><code>` blocks, HTML-escape all special characters.
|
||||
8. **Links in CVE table** — advisory IDs link to the GitHub advisory URL.
|
||||
9. **No external dependencies** — no CDN fonts, no JS libs. Pure HTML/CSS/JS.
|
||||
10. **Dark theme by default** — matches terminal-native developer workflows.
|
||||
|
||||
---
|
||||
|
||||
## Content Rules (unchanged from markdown era)
|
||||
|
||||
- Every finding needs source location, data flow trace, and concrete exploitability.
|
||||
- Never truncate evidence to the point where it loses meaning.
|
||||
- Keep descriptions factual. No speculative language.
|
||||
- The report must be self-contained.
|
||||
- Include ALL tests performed (pentest section), including those that passed.
|
||||
- CVE analysis table is mandatory when dependency vulns exist.
|
||||
- Negative results table is mandatory — reader needs to know what was tested and found secure.
|
||||
126
.github/skills/security-specialist/references/report-schema.json
vendored
Normal file
126
.github/skills/security-specialist/references/report-schema.json
vendored
Normal file
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"$comment": "Schema para findings.json estruturado. validate-findings.cjs lê este arquivo diretamente.",
|
||||
"output_schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"description": "Vulnerabilidade confirmada — report completo e verificado independentemente.",
|
||||
"properties": {
|
||||
"verdict": { "type": "string", "const": "confirmed" },
|
||||
"title": { "type": "string", "description": "Título conciso e padronizado para a vulnerabilidade." },
|
||||
"description": { "type": "string", "description": "Explicação completa da vulnerabilidade. Inclua detalhes de reprodução (PoC input, configuração, output observado) aqui." },
|
||||
"root_cause": { "type": "string", "description": "Uma frase usando template: '[function_or_component] em [file] não [ação ausente], permitindo [consequência]'. DEVE incluir nome de function/component e file." },
|
||||
"intended_behavior": { "type": "string", "description": "O que o dev tentou construir? Explique a lógica de negócio pretendida, não-vulnerável." },
|
||||
"trace": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": { "type": "string", "enum": ["entrypoint", "propagation", "sink"] },
|
||||
"file": { "type": "string", "description": "Caminho exato relativo à raiz do repositório." },
|
||||
"line": { "type": "integer" },
|
||||
"scope": { "type": "string", "description": "Nome de function ou method. Sem parênteses, sem argumentos." },
|
||||
"description": { "type": "string", "description": "Descrição factual do state change ou data movement." }
|
||||
},
|
||||
"required": ["kind", "file", "line", "scope", "description"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"description": "Trace sequencial do entrypoint ao sink, verificado contra source code real. Primeiro step deve ser kind 'entrypoint' e último deve ser kind 'sink'."
|
||||
},
|
||||
"conditions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": { "type": "string", "enum": ["authentication_level", "authorization_role", "user_interaction", "system_configuration", "network_routing", "environmental_dependency", "data_state", "timing_dependency", "third_party_dependency"] },
|
||||
"description": { "type": "string" }
|
||||
},
|
||||
"required": ["kind", "description"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"description": "Pré-requisitos factuais para exploração. Array vazio se explorável por default."
|
||||
},
|
||||
"execution": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"attacker_perspective": { "type": "string", "description": "Quem é o atacante e seu starting point." },
|
||||
"payloads": { "type": "array", "items": { "type": "string" }, "description": "Inputs maliciosos específicos, HTTP requests, ou scripts." },
|
||||
"instructions": { "type": "array", "items": { "type": "string" }, "description": "Array linear de todas ações do atacante do setup até exploração." },
|
||||
"expected_result": { "type": "string", "description": "Resultado observável confirmando exploração bem-sucedida." }
|
||||
},
|
||||
"required": ["attacker_perspective", "payloads", "instructions", "expected_result"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"remediation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"strategy": { "type": "string", "description": "Explicação high-level do fix." },
|
||||
"code_changes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_name": { "type": "string" },
|
||||
"fixed_code": { "type": "string" }
|
||||
},
|
||||
"required": ["file_name", "fixed_code"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["strategy"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"severity": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"likelihood": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"score": { "type": "string", "enum": ["informational", "low", "medium", "high", "critical"] },
|
||||
"reason": { "type": "string" }
|
||||
},
|
||||
"required": ["score", "reason"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"impact": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"score": { "type": "string", "enum": ["informational", "low", "medium", "high", "critical"] },
|
||||
"reason": { "type": "string" }
|
||||
},
|
||||
"required": ["score", "reason"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"overall_severity": { "type": "string", "enum": ["informational", "low", "medium", "high", "critical"] }
|
||||
},
|
||||
"required": ["likelihood", "impact", "overall_severity"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"confidence": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"score": { "type": "string", "enum": ["low", "medium", "high"] },
|
||||
"reason": { "type": "string", "description": "Por que você deu essa confidence. Mencione missing files, complex routing, ou ambiguous data flows." }
|
||||
},
|
||||
"required": ["score", "reason"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["verdict", "title", "description", "root_cause", "intended_behavior", "trace", "conditions", "execution", "remediation", "severity", "confidence"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"description": "Finding rejeitado — o comportamento descrito é factualmente incorreto ou o code path não existe.",
|
||||
"properties": {
|
||||
"verdict": { "type": "string", "const": "rejected" },
|
||||
"reason": { "type": "string", "description": "Explique quais claims específicos no finding estão factualmente errados." }
|
||||
},
|
||||
"required": ["verdict", "reason"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
164
.github/skills/security-specialist/references/scan-artifacts.md
vendored
Normal file
164
.github/skills/security-specialist/references/scan-artifacts.md
vendored
Normal file
@@ -0,0 +1,164 @@
|
||||
# Scan Artifacts Specification
|
||||
|
||||
This document describes the file layout and purpose of each artifact produced by a completed security scan.
|
||||
|
||||
---
|
||||
|
||||
## Directory Structure
|
||||
|
||||
All scan artifacts live in a `.security/` directory at the repository root:
|
||||
|
||||
```
|
||||
.security/
|
||||
├── scan.db # SQLite database (source of truth)
|
||||
├── findings.json # Exported findings — simple format (generated by finalize.py)
|
||||
├── report.md # Human-readable report (generated by finalize.py)
|
||||
├── integrity.sha256 # SHA-256 of findings.json (tamper detection)
|
||||
├── threat-model.md # Repository threat model (if generated)
|
||||
└── scans/
|
||||
└── <timestamp>/
|
||||
├── architecture.md # Phase 1 output (full-scan only)
|
||||
├── findings.json # Structured format — validated against report-schema.json
|
||||
├── security-report.html # Self-contained HTML report
|
||||
├── report.json # Machine-readable summary
|
||||
└── manifest.json # File hashes + completion timestamp
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Artifact Descriptions
|
||||
|
||||
### scan.db — Source of Truth
|
||||
|
||||
A SQLite database containing the complete scan state. This is the authoritative data store that all other artifacts are derived from.
|
||||
|
||||
**Tables:**
|
||||
- `scans` — Scan metadata (id, repo, branch, started_at, completed_at, config)
|
||||
- `findings` — All findings conforming to the schema in `finding-format.md`
|
||||
- `triage_log` — Status change history (who changed what, when, and why)
|
||||
|
||||
**Rules:**
|
||||
- All mutations happen here first. Never edit `findings.json` or `report.md` directly.
|
||||
- The database is append-only during a scan. Findings are inserted, never deleted (status changes use the `status` field).
|
||||
- Triage actions (marking false-positive, accepted-risk, etc.) are recorded with a timestamp and reason in `triage_log`.
|
||||
|
||||
**Typical operations:**
|
||||
```sql
|
||||
-- Count open findings by severity
|
||||
SELECT severity, COUNT(*) FROM findings
|
||||
WHERE scan_id = ? AND status = 'open'
|
||||
GROUP BY severity ORDER BY
|
||||
CASE severity
|
||||
WHEN 'critical' THEN 1
|
||||
WHEN 'high' THEN 2
|
||||
WHEN 'medium' THEN 3
|
||||
WHEN 'low' THEN 4
|
||||
WHEN 'info' THEN 5
|
||||
END;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### findings.json — Sealed Export
|
||||
|
||||
A JSON array of all findings from the scan, exported from `scan.db` at finalization time.
|
||||
|
||||
**Properties:**
|
||||
- Generated by `finalize.py` — never written by hand
|
||||
- Represents a point-in-time snapshot of findings at scan completion
|
||||
- Immutable after generation. If findings change (triage, fixes), re-run finalization to produce a new export
|
||||
- Each entry conforms exactly to the schema in `finding-format.md`
|
||||
|
||||
**Structure:**
|
||||
```json
|
||||
{
|
||||
"scan_id": "f0e1d2c3-b4a5-6789-0123-456789abcdef",
|
||||
"repository": "myorg/myapp",
|
||||
"branch": "main",
|
||||
"finalized_at": "2026-06-24T03:30:00Z",
|
||||
"findings": [
|
||||
{ /* finding object per finding-format.md */ }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### report.md — Human-Readable Report
|
||||
|
||||
The markdown report formatted according to `report-format.md`. Intended for human review, pull request comments, or export to documentation systems.
|
||||
|
||||
**Properties:**
|
||||
- Generated from the same data as `findings.json` at finalization time
|
||||
- Read-only artifact — regenerate rather than edit
|
||||
- Self-contained: readers should not need to consult `scan.db` or `findings.json`
|
||||
|
||||
---
|
||||
|
||||
### integrity.sha256 — Tamper Detection
|
||||
|
||||
A SHA-256 hash of `findings.json`, computed at finalization time.
|
||||
|
||||
**Format:**
|
||||
```
|
||||
<hex-encoded sha256> findings.json
|
||||
```
|
||||
|
||||
Example:
|
||||
```
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 findings.json
|
||||
```
|
||||
|
||||
**Purpose:**
|
||||
- Allows downstream tools (CI gates, compliance checks, dashboards) to verify that `findings.json` has not been modified since finalization
|
||||
- If the hash does not match, the findings export must be considered untrusted and regenerated from `scan.db`
|
||||
|
||||
**Verification:**
|
||||
```bash
|
||||
cd .security/
|
||||
sha256sum -c integrity.sha256
|
||||
```
|
||||
|
||||
Expected output on success: `findings.json: OK`
|
||||
|
||||
---
|
||||
|
||||
### threat-model.md — Repository Threat Model (Optional)
|
||||
|
||||
A structured threat model for the repository, generated on first scan or when explicitly requested. Not regenerated on every scan.
|
||||
|
||||
**Contains:**
|
||||
- Trust boundaries (what's inside vs. outside the security perimeter)
|
||||
- Data flows (what sensitive data moves where)
|
||||
- Entry points (APIs, file uploads, webhooks, CLI inputs)
|
||||
- Assets (databases, credentials, user data, secrets)
|
||||
- Threat actors (who might attack and what they'd target)
|
||||
|
||||
**Rules:**
|
||||
- Only created when explicitly triggered or on first scan of a new repository
|
||||
- Updated manually or on request — not overwritten by routine scans
|
||||
- Informs severity decisions: a finding that crosses a trust boundary is more severe than one contained within a trusted zone
|
||||
|
||||
---
|
||||
|
||||
## Lifecycle
|
||||
|
||||
1. **Scan starts** → `scan.db` is created (or a new scan row is inserted into an existing database)
|
||||
2. **Analysis runs** → Findings are inserted into `scan.db` as they are discovered
|
||||
3. **Triage (optional)** → Agent or human reviews findings, updates statuses in `scan.db`
|
||||
4. **Finalization** → `finalize.py` exports `findings.json`, generates `report.md`, computes `integrity.sha256`
|
||||
5. **Post-seal** → Artifacts are committed, pushed, or attached to a PR. No further modifications without re-finalization.
|
||||
|
||||
---
|
||||
|
||||
## Gitignore Considerations
|
||||
|
||||
The `.security/` directory should generally be committed so findings are tracked alongside code. However:
|
||||
|
||||
- `scan.db` may be gitignored in repositories where only the sealed artifacts matter (reduces churn from SQLite binary diffs)
|
||||
- If `scan.db` is gitignored, `findings.json` + `integrity.sha256` become the durable record
|
||||
|
||||
Recommended `.gitignore` entry when excluding the database:
|
||||
```gitignore
|
||||
.security/scan.db
|
||||
```
|
||||
162
.github/skills/security-specialist/references/severity-policy.md
vendored
Normal file
162
.github/skills/security-specialist/references/severity-policy.md
vendored
Normal file
@@ -0,0 +1,162 @@
|
||||
# Severity Policy
|
||||
|
||||
Practical decision criteria for assigning severity to security findings. Apply this policy consistently — do not assign severity based on gut feeling.
|
||||
|
||||
---
|
||||
|
||||
## Severity Levels
|
||||
|
||||
### Critical
|
||||
|
||||
The vulnerability allows an attacker to fully compromise the system, its data, or its users with minimal effort and no special access.
|
||||
|
||||
**Assign critical when:**
|
||||
- Remote code execution (RCE) is achievable
|
||||
- Authentication can be bypassed entirely, granting full access
|
||||
- PII, credentials, or payment data is directly exposed or exfiltrable
|
||||
- Supply chain compromise: malicious dependency, tampered build artifact, or poisoned CI pipeline
|
||||
- Pre-authentication exploitation — no account or privileges required
|
||||
|
||||
**Examples:**
|
||||
- Unauthenticated endpoint that returns all user records with passwords
|
||||
- Deserialization vulnerability allowing arbitrary command execution
|
||||
- Hardcoded production credentials (database, payment processor, admin tokens)
|
||||
- Dependency with an actively exploited RCE CVE
|
||||
|
||||
---
|
||||
|
||||
### High
|
||||
|
||||
The vulnerability enables significant damage but requires slightly more effort or minimal access (low-privilege account).
|
||||
|
||||
**Assign high when:**
|
||||
- SQL injection or XSS that enables session hijacking or credential theft
|
||||
- Privilege escalation from normal user to admin
|
||||
- SSRF that reaches internal services, metadata endpoints, or private networks
|
||||
- Significant data exposure (not full breach, but sensitive records accessible)
|
||||
- Authentication flaws that weaken but don't fully bypass access control
|
||||
- File upload allowing server-side execution
|
||||
|
||||
**Examples:**
|
||||
- Stored XSS in a comment field that steals admin session cookies
|
||||
- IDOR allowing any authenticated user to read other users' private data
|
||||
- SSRF reaching cloud metadata endpoint (`169.254.169.254`)
|
||||
- JWT signature not verified, allowing role escalation
|
||||
|
||||
---
|
||||
|
||||
### Medium
|
||||
|
||||
The vulnerability has real security impact but is limited in scope, requires chaining, or affects non-critical paths.
|
||||
|
||||
**Assign medium when:**
|
||||
- Stored XSS that cannot access session tokens (HttpOnly cookies in place)
|
||||
- Information disclosure: stack traces, internal file paths, software versions
|
||||
- Missing security headers (CSP, X-Frame-Options) on sensitive pages
|
||||
- Weak cryptography in non-critical paths (e.g., MD5 for non-password hashing)
|
||||
- CSRF on state-changing but non-critical actions
|
||||
- Open redirect usable for phishing
|
||||
|
||||
**Examples:**
|
||||
- Error page leaks full stack trace including internal IP addresses
|
||||
- No CSP header on pages that render user-generated content
|
||||
- Password reset token generated with insufficient entropy (but short-lived)
|
||||
- CSRF on profile display name change (not on password/email change)
|
||||
|
||||
---
|
||||
|
||||
### Low
|
||||
|
||||
The issue has minimal direct security impact but represents a gap in defense-in-depth or hygiene.
|
||||
|
||||
**Assign low when:**
|
||||
- Verbose error messages revealing framework version or minor internals
|
||||
- Missing rate limiting on non-critical endpoints
|
||||
- Minor misconfigurations with no direct exploit path
|
||||
- Dependencies with CVEs that have no practical exploit in this context
|
||||
- Cookie without `Secure` flag in a development-only path
|
||||
- Directory listing enabled but exposing only public assets
|
||||
|
||||
**Examples:**
|
||||
- Server responds with `X-Powered-By: Express` header
|
||||
- No rate limit on the "forgot password" endpoint (but tokens are single-use and short-lived)
|
||||
- Dependency has a CVE for a function the project never calls
|
||||
- CORS allows `*` on a public read-only API with no auth
|
||||
|
||||
---
|
||||
|
||||
### Info
|
||||
|
||||
Not a vulnerability. An observation, best-practice recommendation, or note for future hardening.
|
||||
|
||||
**Assign info when:**
|
||||
- Best practice not followed but no exploitable condition exists
|
||||
- Code quality issue with security implications (e.g., error handling inconsistency)
|
||||
- Suggestion for future improvement (e.g., "consider adding Subresource Integrity")
|
||||
- Informational notes about architecture or trust boundaries
|
||||
|
||||
**Examples:**
|
||||
- Recommend enabling HSTS preload (HSTS is already present, just not preloaded)
|
||||
- Suggest adding `integrity` attributes to CDN script tags
|
||||
- Note that logging does not capture failed authentication attempts
|
||||
|
||||
---
|
||||
|
||||
## Dynamic Baseline
|
||||
|
||||
Severity não é absoluta — é relativa ao que a aplicação é e ao que comparáveis aceitam.
|
||||
|
||||
### Como Calibrar
|
||||
|
||||
1. **Identifique o comparável** em Phase 1 (CMS → outros CMSes, API gateway → outros API gateways, novel app → sem comparável)
|
||||
2. **Verifique se o pattern existe no comparável** — se sim e foi explorado, é finding MAIS FORTE. Se nunca explorado em anos de produção, entenda por quê.
|
||||
3. **Ajuste severity pela distância do padrão aceito** — se TODO app nessa categoria tem o mesmo pattern e ninguém considera vulnerability, não reporte como HIGH.
|
||||
4. **Não use baseline para DESCARTAR** — use para calibrar. Um pattern perigoso é perigoso mesmo se o comparável também o tem.
|
||||
|
||||
### Distinction: HIGH vs MEDIUM para Business Logic
|
||||
|
||||
- **HIGH**: O finding derrota um security boundary explícito. User performa ação que o sistema explicitamente gate atrás de higher role, e a ação tem consequências reais.
|
||||
- **MEDIUM**: Bypass com consequências reais mas limitadas. Requer auth, impacto confinado a dados do atacante, ou conditions uncommon.
|
||||
|
||||
---
|
||||
|
||||
## Don't Overcall
|
||||
|
||||
Common mistakes that inflate severity beyond what the evidence supports:
|
||||
|
||||
| Mistake | Why it's wrong | Correct severity |
|
||||
|---------|---------------|-----------------|
|
||||
| Reflected XSS behind authentication marked as critical | Requires social engineering of an already-authenticated user; session cookies are HttpOnly | Medium (or High if cookies are accessible) |
|
||||
| Missing HSTS marked as critical | HSTS absence alone doesn't enable exploitation; it's defense-in-depth | Low (or Medium if the site handles sensitive auth flows over HTTP) |
|
||||
| Dependency CVE with no reachable code path marked as high | If the vulnerable function is never called, there's no exploit | Low or Info |
|
||||
| Missing rate limiting on login marked as high | Only matters if there's no account lockout, no CAPTCHA, and passwords are weak | Low (escalate to Medium if no compensating controls exist) |
|
||||
| Information disclosure of software version marked as high | Version numbers alone don't enable attack; they help an attacker enumerate but require a corresponding vulnerability | Low |
|
||||
| Self-XSS (user can only attack themselves) marked as medium | No impact on other users; no realistic attack scenario | Info |
|
||||
| CORS misconfiguration on a public API with no auth | If the API is intentionally public and has no user context, CORS is irrelevant | Info |
|
||||
| **Multiple dependency CVEs listed at face value without project context** | If 9 CVEs are listed but only 1 is exploitable due to missing preconditions, reporting "9 CRITICAL CVEs" is misleading and erodes trust | Analyze each individually, assign per-CVE real severity |
|
||||
|
||||
**The rule:** Severity reflects *demonstrated impact*, not *theoretical worst case*. If you can't articulate the realistic attack scenario and its consequences in 2 sentences, you're probably overcalling.
|
||||
|
||||
### CVE Cross-Reference Protocol (Mandatory)
|
||||
|
||||
Before assigning severity to any dependency CVE:
|
||||
|
||||
1. **Read the advisory** — identify the exact precondition (which function, which feature, which config)
|
||||
2. **Grep the codebase** — does the project use that function/feature? Cite the evidence (file:line or "0 results")
|
||||
3. **Check the environment** — does prod have the infrastructure the CVE requires? (CDN, multi-user, Windows, etc.)
|
||||
4. **DAST validate** — did the probe confirm exploitability in localhost? In production?
|
||||
5. **Assign real severity** — based on what you proved, not what the advisory says generically
|
||||
|
||||
A bulk "upgrade all deps" recommendation is fine. But the *severity* must reflect this project, not all projects.
|
||||
|
||||
---
|
||||
|
||||
## Severity Decision Flowchart
|
||||
|
||||
1. **Can an unauthenticated attacker achieve RCE, full data breach, or complete auth bypass?** → Critical
|
||||
2. **Can a low-privilege attacker steal sessions, escalate privileges, or access significant sensitive data?** → High
|
||||
3. **Is there real but limited impact (scoped data leak, partial XSS, missing hardening on sensitive pages)?** → Medium
|
||||
4. **Is it a hygiene gap with no direct exploit path in this context?** → Low
|
||||
5. **Is it purely advisory with no current exploitability?** → Info
|
||||
|
||||
When in doubt between two levels, ask: "Can I demonstrate concrete harm to a user or the system?" If yes, go with the higher level. If not, go lower.
|
||||
Reference in New Issue
Block a user