@kaademos/secure-sdlc 1.0.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/grc-analyst.md +11 -5
- package/.claude/agents/product-manager.md +3 -3
- package/.claude-plugin/marketplace.json +51 -0
- package/.claude-plugin/plugin.json +31 -0
- package/.github/workflows/secure-sdlc-gate.yml +47 -8
- package/CHANGELOG.md +44 -0
- package/CLAUDE.md +1 -0
- package/README.md +65 -15
- package/cli/src/commands/init.js +7 -2
- package/cli/src/utils/stack-detect.js +26 -0
- package/docs/templates/compliance-attestation.md +40 -1
- package/docs/templates/threat-model.md +1 -1
- package/package.json +4 -1
- package/skills/ai-security/SKILL.md +163 -0
- package/skills/compliance-and-audit/SKILL.md +173 -0
- package/skills/security-and-hardening/SKILL.md +117 -0
- package/skills/threat-modeling/SKILL.md +151 -0
- package/stacks/django.md +7 -7
- package/stacks/express.md +6 -6
- package/stacks/fastapi.md +6 -6
- package/stacks/golang.md +274 -0
- package/stacks/nextjs.md +6 -6
- package/stacks/rails.md +6 -6
package/stacks/golang.md
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# Go Security Profile
|
|
2
|
+
|
|
3
|
+
**Language:** Go 1.22+
|
|
4
|
+
**Frameworks:** net/http (stdlib), Gin, Echo, Fiber
|
|
5
|
+
**ASVS Baseline:** L2
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Go's Standard Library Is Safe — Until You Reach Around It
|
|
10
|
+
|
|
11
|
+
Go's stdlib gives you memory safety, `html/template` context-aware escaping, and
|
|
12
|
+
`database/sql` placeholders for free. Almost every Go web vulnerability comes from
|
|
13
|
+
*opting out* of these: using `text/template` for HTML, building SQL with `fmt.Sprintf`,
|
|
14
|
+
running shell strings through `exec.Command("sh", "-c", ...)`, or enabling a wildcard CORS
|
|
15
|
+
policy alongside credentials. This profile focuses on those opt-out traps.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## XSS — Use `html/template`, Never `text/template`, for HTML
|
|
20
|
+
|
|
21
|
+
`html/template` escapes output based on context (HTML body, attribute, JS, URL, CSS).
|
|
22
|
+
`text/template` performs **zero** escaping and must never render attacker-influenced HTML.
|
|
23
|
+
|
|
24
|
+
```go
|
|
25
|
+
import (
|
|
26
|
+
"html/template" // ✓ context-aware auto-escaping
|
|
27
|
+
// "text/template" // ✗ NO escaping — XSS if used for HTML
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
// ✓ Safe — html/template escapes .Name in HTML context
|
|
31
|
+
var tmpl = template.Must(template.New("p").Parse(`<p>Hello {{ .Name }}</p>`))
|
|
32
|
+
tmpl.Execute(w, map[string]string{"Name": userInput}) // <script> becomes <script>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```go
|
|
36
|
+
// ✗ Unsafe — template.HTML tells the engine "this is already safe", disabling escaping
|
|
37
|
+
tmpl.Execute(w, map[string]any{"Bio": template.HTML(user.Bio)}) // stored XSS
|
|
38
|
+
|
|
39
|
+
// ✓ Safe — sanitise untrusted HTML first, then mark trusted
|
|
40
|
+
import "github.com/microcosm-cc/bluemonday"
|
|
41
|
+
|
|
42
|
+
p := bluemonday.UGCPolicy()
|
|
43
|
+
safe := template.HTML(p.Sanitize(user.Bio)) // allow-list of tags/attrs only
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**Other escaping bypasses to flag in review:** `template.JS`, `template.URL`,
|
|
47
|
+
`template.CSS`, `template.HTMLAttr` — each disables escaping for that context. They are
|
|
48
|
+
only safe with values your code fully controls, never with user input.
|
|
49
|
+
|
|
50
|
+
> Framework note: Gin's `c.HTML()`, Echo's `c.Render()`, and Fiber's `html/v2` engine all
|
|
51
|
+
> build on `html/template`, so context escaping applies. Setting `Content-Type: text/html`
|
|
52
|
+
> by hand and writing a `fmt.Sprintf`'d string with `c.String()`/`w.Write()` bypasses it.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## SQL Injection — Placeholders, Never `fmt.Sprintf`
|
|
57
|
+
|
|
58
|
+
### `database/sql`
|
|
59
|
+
|
|
60
|
+
```go
|
|
61
|
+
// ✗ SQL injection — string formatting builds the query
|
|
62
|
+
q := fmt.Sprintf("SELECT * FROM users WHERE email = '%s'", email)
|
|
63
|
+
rows, err := db.Query(q)
|
|
64
|
+
|
|
65
|
+
// ✓ Parameterised — driver sends value separately from SQL
|
|
66
|
+
rows, err := db.Query("SELECT * FROM users WHERE email = $1", email) // pq / pgx
|
|
67
|
+
rows, err := db.Query("SELECT * FROM users WHERE email = ?", email) // MySQL / SQLite
|
|
68
|
+
err := db.QueryRow("SELECT id FROM users WHERE email = $1", email).Scan(&id)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Identifiers (table/column names, `ORDER BY`) **cannot** be parameterised — allow-list them:
|
|
72
|
+
|
|
73
|
+
```go
|
|
74
|
+
// ✗ Unsafe — attacker controls the ORDER BY clause
|
|
75
|
+
db.Query("SELECT * FROM users ORDER BY " + r.URL.Query().Get("sort"))
|
|
76
|
+
|
|
77
|
+
// ✓ Safe — validate against a fixed set before interpolating
|
|
78
|
+
var allowedSort = map[string]string{"name": "name", "created": "created_at"}
|
|
79
|
+
col, ok := allowedSort[r.URL.Query().Get("sort")]
|
|
80
|
+
if !ok {
|
|
81
|
+
col = "created_at"
|
|
82
|
+
}
|
|
83
|
+
db.Query("SELECT * FROM users ORDER BY " + col) // col is now a known-safe literal
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### GORM
|
|
87
|
+
|
|
88
|
+
```go
|
|
89
|
+
// ✓ Safe — ? placeholders are escaped by GORM
|
|
90
|
+
db.Where("email = ?", email).First(&user)
|
|
91
|
+
db.Raw("SELECT * FROM users WHERE email = ?", email).Scan(&user)
|
|
92
|
+
|
|
93
|
+
// ✗ Unsafe — Sprintf into the condition string defeats GORM's escaping
|
|
94
|
+
db.Where(fmt.Sprintf("email = '%s'", email)).First(&user)
|
|
95
|
+
|
|
96
|
+
// ✗ Unsafe — user-controlled column name in a struct/map update can corrupt other fields
|
|
97
|
+
db.Model(&user).Updates(userControlledMap) // allow-list keys, or use a typed struct
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## CORS — Never Wildcard Origin *with* Credentials
|
|
103
|
+
|
|
104
|
+
`Access-Control-Allow-Origin: *` combined with `Allow-Credentials: true` is rejected by
|
|
105
|
+
browsers, so the usual "fix" is reflecting the request `Origin` — which silently allows
|
|
106
|
+
**every** site. Always use an explicit allow-list.
|
|
107
|
+
|
|
108
|
+
```go
|
|
109
|
+
// stdlib via github.com/rs/cors
|
|
110
|
+
import "github.com/rs/cors"
|
|
111
|
+
|
|
112
|
+
// ✗ Unsafe — allows any origin to make credentialed requests
|
|
113
|
+
c := cors.New(cors.Options{
|
|
114
|
+
AllowedOrigins: []string{"*"},
|
|
115
|
+
AllowCredentials: true,
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
// ✓ Safe — explicit origins, credentials only for those
|
|
119
|
+
c := cors.New(cors.Options{
|
|
120
|
+
AllowedOrigins: []string{"https://app.example.com"},
|
|
121
|
+
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
|
|
122
|
+
AllowCredentials: true,
|
|
123
|
+
MaxAge: 300,
|
|
124
|
+
})
|
|
125
|
+
handler := c.Handler(mux)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
```go
|
|
129
|
+
// Gin — github.com/gin-contrib/cors
|
|
130
|
+
import "github.com/gin-contrib/cors"
|
|
131
|
+
|
|
132
|
+
// ✗ Unsafe — AllowAllOrigins + credentials reflects every Origin
|
|
133
|
+
r.Use(cors.New(cors.Config{AllowAllOrigins: true, AllowCredentials: true}))
|
|
134
|
+
|
|
135
|
+
// ✓ Safe — explicit list
|
|
136
|
+
r.Use(cors.New(cors.Config{
|
|
137
|
+
AllowOrigins: []string{"https://app.example.com"},
|
|
138
|
+
AllowCredentials: true,
|
|
139
|
+
}))
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
```go
|
|
143
|
+
// Echo — middleware.CORSWithConfig (AllowOrigins) and
|
|
144
|
+
// Fiber — github.com/gofiber/fiber/v2/middleware/cors (AllowOrigins) follow the same rule:
|
|
145
|
+
// list real origins; never set AllowOrigins:"*" together with AllowCredentials:true.
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## Security Headers — Stdlib Has None
|
|
151
|
+
|
|
152
|
+
`net/http` sends no security headers. Add them via middleware on every response.
|
|
153
|
+
|
|
154
|
+
```go
|
|
155
|
+
func secureHeaders(next http.Handler) http.Handler {
|
|
156
|
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
157
|
+
h := w.Header()
|
|
158
|
+
h.Set("Content-Security-Policy", "default-src 'self'")
|
|
159
|
+
h.Set("X-Content-Type-Options", "nosniff")
|
|
160
|
+
h.Set("X-Frame-Options", "DENY")
|
|
161
|
+
h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload")
|
|
162
|
+
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
|
163
|
+
next.ServeHTTP(w, r)
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
// Or use github.com/unrolled/secure for a configurable equivalent.
|
|
167
|
+
// Gin: gin-contrib/secure · Echo: middleware.Secure() · Fiber: middleware/helmet
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## Authentication & Passwords
|
|
173
|
+
|
|
174
|
+
```go
|
|
175
|
+
// ✓ Password hashing — bcrypt (cost ≥ 12) or argon2id
|
|
176
|
+
import "golang.org/x/crypto/bcrypt"
|
|
177
|
+
|
|
178
|
+
hash, err := bcrypt.GenerateFromPassword([]byte(pw), 12)
|
|
179
|
+
err = bcrypt.CompareHashAndPassword(hash, []byte(pw)) // constant-time
|
|
180
|
+
|
|
181
|
+
// ✗ Never — fast/unsalted hashes for passwords
|
|
182
|
+
sum := sha256.Sum256([]byte(pw)) // brute-forceable
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
```go
|
|
186
|
+
// ✓ Constant-time comparison for tokens/HMACs — avoid timing leaks
|
|
187
|
+
import "crypto/subtle"
|
|
188
|
+
|
|
189
|
+
if subtle.ConstantTimeCompare(got, want) == 1 { /* match */ }
|
|
190
|
+
|
|
191
|
+
// ✗ Unsafe — == short-circuits and leaks length/prefix via timing
|
|
192
|
+
if string(got) == string(want) { /* ... */ }
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
JWT: pin the expected algorithm in the keyfunc to defeat `alg: none` / algorithm-confusion:
|
|
196
|
+
|
|
197
|
+
```go
|
|
198
|
+
token, err := jwt.Parse(raw, func(t *jwt.Token) (any, error) {
|
|
199
|
+
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { // ✓ reject unexpected alg
|
|
200
|
+
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
|
201
|
+
}
|
|
202
|
+
return secret, nil
|
|
203
|
+
})
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## Command Injection & SSRF
|
|
209
|
+
|
|
210
|
+
```go
|
|
211
|
+
import "os/exec"
|
|
212
|
+
|
|
213
|
+
// ✗ Command injection — user input in a shell string
|
|
214
|
+
exec.Command("sh", "-c", "convert "+userFile+" out.png").Run()
|
|
215
|
+
|
|
216
|
+
// ✓ Safe — pass args as a slice; no shell, no word-splitting
|
|
217
|
+
exec.Command("convert", userFile, "out.png").Run()
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
```go
|
|
221
|
+
// ✗ SSRF — fetching a user-supplied URL lets attackers hit internal services / metadata
|
|
222
|
+
http.Get(r.FormValue("url"))
|
|
223
|
+
|
|
224
|
+
// ✓ Resolve + validate the host against an allow-list and block private/link-local IPs
|
|
225
|
+
// before dialing (deny 169.254.169.254, 10/8, 127/8, ::1, etc.).
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
## Secrets & Config
|
|
231
|
+
|
|
232
|
+
```go
|
|
233
|
+
// ✓ Read secrets from the environment / a secrets manager — never hardcode
|
|
234
|
+
dsn := os.Getenv("DATABASE_URL")
|
|
235
|
+
if dsn == "" {
|
|
236
|
+
log.Fatal("DATABASE_URL is required")
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ✗ Never commit credentials
|
|
240
|
+
const apiKey = "sk_live_8f2b..." // gitleaks/gosec will flag this
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Run `gitleaks` in CI and keep `.env` out of git. Use `errors.Is`/`%w` and return generic
|
|
244
|
+
client errors — never write `err.Error()` (which can leak DSNs, paths, SQL) into responses.
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
## ASVS Controls for Go Projects
|
|
249
|
+
|
|
250
|
+
| ASVS Ref | Control | Go Implementation |
|
|
251
|
+
|----------|---------|-------------------|
|
|
252
|
+
| V2.2.1 | Input validation | `go-playground/validator` on bound structs |
|
|
253
|
+
| V1.3.1 | Output encoding (XSS) | `html/template`; sanitise with `bluemonday` |
|
|
254
|
+
| V1.2.4 | No SQL injection | `database/sql` placeholders; GORM `?` params |
|
|
255
|
+
| V1.2.5 | No OS command injection | `exec.Command` with arg slices, no `sh -c` |
|
|
256
|
+
| V11.4.2 | Password storage | `bcrypt` (cost ≥ 12) or `argon2id` |
|
|
257
|
+
| V3.5.1 | CSRF | `gorilla/csrf` for cookie-based sessions |
|
|
258
|
+
| V4.1.1 | Security headers | `unrolled/secure` middleware |
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
## Recommended Security Tooling (2026)
|
|
263
|
+
|
|
264
|
+
| Category | Tool |
|
|
265
|
+
|----------|------|
|
|
266
|
+
| SAST | `gosec` (`securego/gosec`) |
|
|
267
|
+
| Vulnerable dependencies | `govulncheck` (official, stdlib-aware) |
|
|
268
|
+
| Secret scanning | `gitleaks` |
|
|
269
|
+
| Input validation | `go-playground/validator` |
|
|
270
|
+
| HTML sanitisation | `bluemonday` |
|
|
271
|
+
| Security headers | `unrolled/secure` |
|
|
272
|
+
| CSRF | `gorilla/csrf` |
|
|
273
|
+
| Password hashing | `golang.org/x/crypto/bcrypt`, `argon2` |
|
|
274
|
+
| CORS | `rs/cors`, `gin-contrib/cors` |
|
package/stacks/nextjs.md
CHANGED
|
@@ -164,12 +164,12 @@ export async function createPost(formData: FormData) {
|
|
|
164
164
|
|
|
165
165
|
| ASVS Ref | Control | Next.js Implementation |
|
|
166
166
|
|----------|---------|----------------------|
|
|
167
|
-
|
|
|
168
|
-
|
|
|
169
|
-
|
|
|
170
|
-
|
|
|
171
|
-
|
|
|
172
|
-
|
|
|
167
|
+
| V8.3.1 | Authentication on all endpoints | `getServerSession()` in every route handler and Server Action |
|
|
168
|
+
| V8.2.2 | Object-level authorisation | Check `resource.userId === session.user.id` before returning/modifying |
|
|
169
|
+
| V2.2.1 | Input validation | Zod schemas on all Server Action inputs |
|
|
170
|
+
| V16.2.5 | Log security events | Log auth events in NextAuth callbacks |
|
|
171
|
+
| V12.2.1 | TLS everywhere | Enforced by Vercel/host; add HSTS header |
|
|
172
|
+
| V4.1.1 | Security headers | `securityHeaders` in next.config.js |
|
|
173
173
|
|
|
174
174
|
---
|
|
175
175
|
|
package/stacks/rails.md
CHANGED
|
@@ -225,12 +225,12 @@ session fixation, and many other Rails-specific issues.
|
|
|
225
225
|
|
|
226
226
|
| ASVS Ref | Control | Rails Implementation |
|
|
227
227
|
|----------|---------|---------------------|
|
|
228
|
-
|
|
|
229
|
-
|
|
|
230
|
-
|
|
|
231
|
-
|
|
|
232
|
-
|
|
|
233
|
-
|
|
|
228
|
+
| V6.2.1 | Password complexity | Devise validates :password strength |
|
|
229
|
+
| V6.3.1 | Account lockout | Devise :lockable |
|
|
230
|
+
| V8.3.1 | Auth on all actions | `before_action :authenticate_user!` |
|
|
231
|
+
| V8.2.2 | Object-level auth | Pundit policies with `authorize @resource` |
|
|
232
|
+
| V1.2.4 | No SQL injection | ActiveRecord ORM; never string-interpolate in where() |
|
|
233
|
+
| V3.5.1 | CSRF | `protect_from_forgery` (default) |
|
|
234
234
|
|
|
235
235
|
---
|
|
236
236
|
|