diff --git a/.coridor/semgrep.yml b/.coridor/semgrep.yml new file mode 100644 index 0000000..78a6beb --- /dev/null +++ b/.coridor/semgrep.yml @@ -0,0 +1,278 @@ +# coridor-sdlc managed file - synced from Coridor-ai/coridor-sdlc; edit there, not here. template-version: 68006a4a0e6d7587f595b7c8f71dc8b5304ba75123fb3d618aef2ff38df89c78 +# Coridor custom Semgrep rules -- GDPR / data-protection focus. +# Distributed to product repos as .coridor/semgrep.yml by the sync layer; the +# pr-gate sast-semgrep job adds that file as an extra --config when present. +# Severity contract: ERROR blocks the PR, WARNING/INFO are reported only. +# Test locally: semgrep scan --config .coridor/semgrep.yml --metrics=off +# (in coridor-sdlc itself the source lives at semgrep/pii-rules.yml) +rules: + - id: coridor-pii-in-log-js + languages: [typescript, javascript] + severity: WARNING + message: >- + PII-shaped value (email/phone/SSN/DOB/card-number style identifier) + passed to a logger. + Personal data in logs spreads to log pipelines and backups where it + cannot be selectively erased -- GDPR Art. 5(1)(c) data minimisation and + Art. 17 erasure. + patterns: + - pattern-either: + - pattern: console.$METHOD(..., $PII, ...) + - pattern: logger.$METHOD(..., $PII, ...) + - pattern: log.$METHOD(..., $PII, ...) + # A bare string literal is a constant, not a PII-carrying value -- + # console.log("Email sent") must not fire. Identifiers, member + # accesses, and interpolations still bind to $PII. + - metavariable-pattern: + metavariable: $PII + patterns: + - pattern-not: '"..."' + - metavariable-regex: + metavariable: $PII + regex: (?i).*(email|e_mail|phone|mobile|ssn|social_?security|passport|date_?of_?birth|birth_?date|\bdob\b|credit_?card|card_?number|iban|national_?id|tax_?id|driver_?licen).* + metadata: + category: security + confidence: LOW + compliance: "GDPR Art. 5(1)(c), Art. 17, Art. 32" + + - id: coridor-pii-in-log-python + languages: [python] + severity: WARNING + message: >- + PII-shaped value (email/phone/SSN/DOB/card-number style identifier) + passed to print or a logger. + Personal data in logs spreads to log pipelines and backups where it + cannot be selectively erased -- GDPR Art. 5(1)(c) data minimisation and + Art. 17 erasure. + patterns: + - pattern-either: + - pattern: print(..., $PII, ...) + - pattern: logging.$METHOD(..., $PII, ...) + - pattern: logger.$METHOD(..., $PII, ...) + - pattern: log.$METHOD(..., $PII, ...) + # A bare string literal is a constant, not a PII-carrying value -- + # print("email queued") must not fire. Identifiers and f-string + # interpolations still bind to $PII. + - metavariable-pattern: + metavariable: $PII + patterns: + - pattern-not: '"..."' + - metavariable-regex: + metavariable: $PII + regex: (?i).*(email|e_mail|phone|mobile|ssn|social_?security|passport|date_?of_?birth|birth_?date|\bdob\b|credit_?card|card_?number|iban|national_?id|tax_?id|driver_?licen).* + metadata: + category: security + confidence: LOW + compliance: "GDPR Art. 5(1)(c), Art. 17, Art. 32" + + - id: coridor-pii-in-error-message-js + languages: [typescript, javascript] + severity: WARNING + message: >- + PII-shaped value interpolated into a thrown error message. + Error messages end up in logs, monitoring, and API responses, leaking + personal data outside its intended processing scope -- GDPR Art. 32. + patterns: + - pattern: throw new $EXC(..., <... $PII ...>, ...) + # Same literal exclusion as the log rules above: a constant message + # (throw new Error("Email is invalid")) carries no personal data. + - metavariable-pattern: + metavariable: $PII + patterns: + - pattern-not: '"..."' + - metavariable-regex: + metavariable: $PII + regex: (?i).*(email|e_mail|phone|mobile|ssn|social_?security|passport|date_?of_?birth|birth_?date|\bdob\b|credit_?card|card_?number|iban|national_?id|tax_?id|driver_?licen).* + metadata: + category: security + confidence: LOW + compliance: "GDPR Art. 32" + + - id: coridor-pii-in-error-message-python + languages: [python] + severity: WARNING + message: >- + PII-shaped value interpolated into a raised exception message. + Error messages end up in logs, monitoring, and API responses, leaking + personal data outside its intended processing scope -- GDPR Art. 32. + patterns: + - pattern: raise $EXC(..., <... $PII ...>, ...) + # Same literal exclusion as the log rules above: a constant message + # (raise ValueError("Email is invalid")) carries no personal data. + - metavariable-pattern: + metavariable: $PII + patterns: + - pattern-not: '"..."' + - metavariable-regex: + metavariable: $PII + regex: (?i).*(email|e_mail|phone|mobile|ssn|social_?security|passport|date_?of_?birth|birth_?date|\bdob\b|credit_?card|card_?number|iban|national_?id|tax_?id|driver_?licen).* + metadata: + category: security + confidence: LOW + compliance: "GDPR Art. 32" + + - id: coridor-sql-built-from-request-js + mode: taint + languages: [typescript, javascript] + severity: ERROR + message: >- + A query-execution call receives a value derived from request input. + String-built SQL with user-controlled data is SQL injection (OWASP A03, + CWE-89); use parameterized queries or the query builder. + # Taint mode traces the tainted value through variable assignment, not + # just inline concatenation at the call site -- a bare pattern match on + # $DB.$METHOD($ARG, ...) only sees the call-site expression, so + # `const sql = "..." + req.params.id; db.query(sql)` bound $ARG to the + # identifier `sql` and the SQL-keyword/concatenation regex on it never + # matched. No sanitizer is declared: passing the value through any + # unrecognized function (escape/sanitize helpers included) does not + # clear the taint label by default. + pattern-sources: + - pattern-either: + - pattern: req.$X + - pattern: request.$X + pattern-sinks: + - patterns: + - pattern: $DB.$METHOD($SINK, ...) + - metavariable-regex: + metavariable: $METHOD + regex: ^(query|execute|raw)$ + # Exclude bound-parameter sinks: a prepared statement passes tainted + # values as an array (stmt.execute([req.id])) -- that is the SAFE + # parameterized form, not string-built SQL. A SQL string is never an + # array literal, so this drops the false positive without masking a + # real string-built query. + - metavariable-pattern: + metavariable: $SINK + patterns: + - pattern-not: "[...]" + - focus-metavariable: $SINK + metadata: + category: security + confidence: MEDIUM + cwe: "CWE-89" + owasp: "A03:2021" + + - id: coridor-sql-built-from-request-python + mode: taint + languages: [python] + severity: ERROR + message: >- + A query-execution call receives a value derived from request input. + String-built SQL with user-controlled data is SQL injection (OWASP A03, + CWE-89); pass parameters separately (cursor.execute(sql, params)). + # Same taint-tracing rationale as the JS rule above: catches + # `sql = f"..." ; cur.execute(sql)`, which a call-site-only pattern + # match cannot see. + pattern-sources: + - pattern-either: + - pattern: request.$X + - pattern: flask.request.$X + pattern-sinks: + - patterns: + - pattern: $CUR.execute($SINK, ...) + # Exclude bound-parameter sinks: prepared execute passes tainted + # values as a tuple/list (stmt.execute((req.id,))) -- the SAFE + # parameterized form. A SQL string is never a tuple or list literal. + - metavariable-pattern: + metavariable: $SINK + patterns: + - pattern-not: "[...]" + - pattern-not: "($X, ...)" + - focus-metavariable: $SINK + metadata: + category: security + confidence: MEDIUM + cwe: "CWE-89" + owasp: "A03:2021" + + - id: coridor-cleartext-http-endpoint + languages: [typescript, javascript, python] + severity: WARNING + message: >- + Hardcoded http:// (non-TLS) URL for what looks like a service endpoint. + Personal data in transit must be encrypted -- GDPR Art. 32(1)(a); use + https:// everywhere except loopback. + # Text-level regex match (the rule spans three languages); the broken + # `pattern: '"=~/.../"'` form matched a string literal containing that + # literal text and never fired. pattern-regex scans raw source like the + # coridor-tls-verification-disabled rule below. pattern-not-regex drops + # loopback/bind-any hosts and well-known XML-namespace identifiers + # (w3.org etc.), which are not data-carrying endpoints. The trailing + # ([:/?#\s"'`]|$) anchors the hostname boundary so a non-loopback host + # whose name merely starts with one of these (e.g. http://localhost.example, + # a different external host) is NOT excluded -- the classic substring/ + # host-prefix confusion that bites unanchored CORS origin checks. + patterns: + - pattern-regex: 'http://[\w.\-\[\]:]+' + - pattern-not-regex: 'http://(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|(www\.)?w3\.org|purl\.org|xmlns\.com)([:/?#\s"''`]|$)' + metadata: + category: security + confidence: LOW + compliance: "GDPR Art. 32(1)(a)" + + - id: coridor-hardcoded-jwt-secret-js + languages: [typescript, javascript] + severity: ERROR + message: >- + Hardcoded string literal used as a JWT signing/verification secret. + A secret in source is a credential leak (CWE-798) and forgeable session + tokens; load it from configuration or a secret manager. + patterns: + - pattern-either: + - pattern: $JWT.sign($PAYLOAD, "...", ...) + - pattern: $JWT.verify($TOKEN, "...", ...) + - metavariable-regex: + metavariable: $JWT + regex: (?i).*(jwt|jsonwebtoken|jose).* + metadata: + category: security + confidence: HIGH + cwe: "CWE-798" + + - id: coridor-private-key-literal + languages: [typescript, javascript, python] + severity: ERROR + message: >- + PEM private key material embedded in source code. + Committed keys are compromised keys (CWE-798); revoke it, rotate, and + load keys from a secret manager. + # ERROR-severity block: must fire on any embedded PEM private-key header. + # The broken `pattern: '"=~/.../"'` form matched a string literal whose + # content was that literal text and never fired -- a real gate gap. + # pattern-regex scans raw source so it catches keys in any of the three + # languages, in strings or comments alike. + pattern-regex: '-----BEGIN[ A-Z]*PRIVATE KEY-----' + metadata: + category: security + confidence: HIGH + cwe: "CWE-798" + + - id: coridor-tls-verification-disabled + languages: [typescript, javascript, python] + severity: ERROR + message: >- + TLS certificate verification disabled (rejectUnauthorized false, + NODE_TLS_REJECT_UNAUTHORIZED=0, or verify=False). + Disabling verification allows man-in-the-middle interception of any + data in transit, including personal data -- GDPR Art. 32, CWE-295. + # Raw-source regexes (the rule spans three languages), so matches inside + # comments fire too -- accepted, same tradeoff the private-key rule above + # documents: prose that spells out the exact disable syntax is rare and + # cheap to reword, and a text-level match cannot tell the difference. + # The env-var regex anchors on a single `=` (assignment): `["'\]\s]*` + # spans the `"]` of a bracket lookup and `(?!=)` rejects `==`/`===`, so + # a guard that merely COMPARES the variable does not fire. The Python + # kwarg regex requires a non-word left boundary so unrelated identifiers + # that end in "verify" (skip_verify = False) do not fire; `.verify` still + # does (session.verify = False disables verification in requests). + patterns: + - pattern-either: + - pattern-regex: rejectUnauthorized\s*:\s*false + - pattern-regex: NODE_TLS_REJECT_UNAUTHORIZED["'\]\s]*=(?!=)\s*["']?0 + - pattern-regex: (?") MUST stay distinct from the reusable +# workflow's group: the called datadog-code-security.yml keys its group to a +# fixed "datadog-code-security-reusable-" prefix precisely so the two never +# match. If they matched, GitHub would see the same concurrency group on parent +# and child and cancel the run at startup as a deadlock (do not rename this +# group to whatever the reusable uses). +concurrency: + group: datadog-code-security-${{ github.ref }} + cancel-in-progress: true + +jobs: + datadog-code-security: + # Caller jobs that `uses:` a reusable workflow cannot set runs-on or + # timeout-minutes; those live on the jobs inside the reusable workflow. + # This permissions block is the ceiling for every job in the called + # workflow -- all of them need only contents: read. + permissions: + contents: read + uses: Coridor-ai/coridor-sdlc/.github/workflows/datadog-code-security.yml@main + # Explicit map, not `secrets: inherit`: inherit hands the called workflow + # every secret the caller can see, and the Semgrep registry rule + # yaml.github-actions.security.secrets-inherit flags it at ERROR in the + # pr-gate. List exactly what the reusable workflow's workflow_call + # contract declares; when the contract grows a secret, add it here (the + # sync will carry the change to every repo). coridor-sdlc's self-check + # fails if this map and the contract ever disagree. + secrets: + DD_API_KEY: ${{ secrets.DD_API_KEY }} + DD_APP_KEY: ${{ secrets.DD_APP_KEY }} diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml new file mode 100644 index 0000000..1d41de7 --- /dev/null +++ b/.github/workflows/pr-gate.yml @@ -0,0 +1,60 @@ +# coridor-sdlc managed file - synced from Coridor-ai/coridor-sdlc; edit there, not here. template-version: 48088a2647033ecbb029287acef55403d6369bb05d4d8e3b8ee451257a473f0c +# +# Thin caller for the org-wide PR gate. The real jobs (secrets-scan, +# sast-semgrep, claude-review, pr-gate-summary) live in the reusable workflow +# at Coridor-ai/coridor-sdlc/.github/workflows/pr-gate.yml. Datadog Code +# Security runs on push from datadog-code-security.yml, not in this gate. +# +# Why @main, not a pinned tag: +# main is the single source of continuous enforcement - a gate improvement +# protects every repo on its next PR, no rollout step. And @main is not an +# uncontrolled ref: every change to it goes through a PR on coridor-sdlc, +# the gate runs on that PR (self-check), and one human approval is required. +# Full rationale: VERSIONING.md in coridor-sdlc. +# +# Stability-sensitive repo? Pin a release tag instead: +# uses: Coridor-ai/coridor-sdlc/.github/workflows/pr-gate.yml@sdlc-v1 +# Pinned repos stop receiving gate updates until the pin moves - pin +# deliberately and note it in the repo README. +# +# The status check this produces - the one branch protection requires - is +# "pr-gate / pr-gate-summary". + +name: pr-gate + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +# One gate run per PR: a superseded push cancels the in-flight run instead of +# burning Actions minutes on a stale diff. This caller's group ("pr-gate-") +# MUST stay distinct from the reusable workflow's group: the called pr-gate.yml +# keys its group to a fixed "pr-gate-reusable-" prefix precisely so the two +# never match. If they matched, GitHub would see the same concurrency group on +# parent and child and cancel the run at startup as a deadlock (do not rename +# this group to whatever the reusable uses). +concurrency: + group: pr-gate-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + pr-gate: + # Caller jobs that `uses:` a reusable workflow cannot set runs-on or + # timeout-minutes; those live on the jobs inside the reusable workflow. + # This permissions block is the ceiling for every job in the called + # workflow - the union of its per-job least-privilege blocks. + permissions: + contents: read + pull-requests: write + issues: read + uses: Coridor-ai/coridor-sdlc/.github/workflows/pr-gate.yml@main + # Explicit map, not `secrets: inherit`: inherit hands the called workflow + # every secret the caller can see, and the Semgrep registry rule + # yaml.github-actions.security.secrets-inherit flags it at ERROR - which + # blocks this very gate. List exactly what the reusable workflow's + # workflow_call contract declares; when the contract grows a secret, add + # it here (the sync will carry the change to every repo). coridor-sdlc's + # self-check fails if this map and the contract ever disagree. + secrets: + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..153716c --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,10 @@ +# coridor-sdlc managed file - synced from Coridor-ai/coridor-sdlc; edit there, not here. template-version: 6cfe43a9fd6a09eafa8ba6100fd1aad2be054c9e752753077beb7939f766cd8b +# +# Default owner for everything in this repo. Review requests route here +# automatically on every PR. +# +# When a team handle is designated for review routing (e.g. @Coridor-ai/eng), +# replace @hub-coridor with it. Add path-specific owners below the default as +# ownership splits. + +* @hub-coridor diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..838c133 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,39 @@ + + +# Security Policy + +## Reporting a vulnerability + +Email **hub@coridor.ai**. Include what you found, where, reproduction steps, and impact as you understand it. + +Do NOT open a public issue for a vulnerability. Do not put exploit details in PR descriptions, commit messages, or comments. Public disclosure before a fix ships puts every user at risk. + +## What happens next + +- Acknowledgment within 2 business days. +- Severity assessment within 5 business days of acknowledgment. +- Remediation runs on severity-based SLAs, aligned to CVSS v3.1 bands: + +| Severity | CVSS | Remediation SLA | +|---|---|---| +| Critical | 9.0-10.0 | 7 days | +| High | 7.0-8.9 | 30 days | +| Medium | 4.0-6.9 | 90 days | +| Low | 0.1-3.9 | 180 days | + +The internal [vulnerability management policy](https://github.com/Coridor-ai/coridor-sdlc/blob/main/docs/policies/vulnerability-management-policy.md) is canonical; if this table and the policy disagree, the policy wins. (The link requires Coridor-ai org access -- the table above is the commitment to external reporters.) + +We'll keep you informed through triage and fix, and credit you on disclosure unless you'd rather we didn't. + +## Supported versions + +We support branches, not version numbers. The branch model is `dev -> uat -> main` (see coridor-sdlc, ADR-0010). + +| Branch | Meaning | Security fixes | +|--------|---------|----------------| +| `main` | production | [ok] always - hotfixes land here first when critical | +| `uat` | acceptance candidate | [ok] via promotion from `dev` or hotfix back-merge | +| `dev` | integration | [~] fixed in the normal flow; not independently patched | +| anything else (old tags, archived repos) | - | [-] not supported | + +Last updated: 2026-07-02 diff --git a/static-analysis.datadog.yml b/static-analysis.datadog.yml new file mode 100644 index 0000000..13f5039 --- /dev/null +++ b/static-analysis.datadog.yml @@ -0,0 +1,28 @@ +# coridor-sdlc managed file - synced from Coridor-ai/coridor-sdlc; edit there, not here. template-version: c7c14f45e1203be64fb31f251375e392d38256bdcc10692a7eaa2944f0757ce5 +# Default Datadog Static Analysis config for Coridor repos. +# Schema: legacy static-analysis.datadog.yml (rulesets/ignore), still +# supported by datadog-static-analyzer; see doc/legacy_config.md in +# DataDog/datadog-static-analyzer. +# Ruleset names verified against DataDog/datadog-static-analyzer RULESETS.md. +# The datadog-code-security dd-static-analysis job writes this same default +# when a repo does not ship the file -- keep the inline default in +# .github/workflows/datadog-code-security.yml in sync (self-check.yml enforces +# it). +schema-version: v1 +rulesets: + - typescript-common-security + - typescript-node-security + - typescript-browser-security + - typescript-best-practices + - javascript-common-security + - javascript-node-security + - javascript-browser-security + - javascript-best-practices + - python-security + - python-best-practices +ignore: + - "**/node_modules" + - "**/dist" + - "**/build" + - "**/coverage" + - "**/.next" diff --git a/trufflehog/exclude-paths.txt b/trufflehog/exclude-paths.txt new file mode 100644 index 0000000..9490dbe --- /dev/null +++ b/trufflehog/exclude-paths.txt @@ -0,0 +1,42 @@ +# coridor-sdlc managed file - synced from Coridor-ai/coridor-sdlc; edit there, not here. template-version: 41f6a182263c2ce1ae35722307dad61529e62090f292be0682798970aa633630 +# TruffleHog exclude paths for the Coridor PR gate (secrets-scan job). +# One Go regex per line, matched unanchored against repo-relative file paths. +# TruffleHog skips comment (#) and blank lines (pkg/common/filter.go). +# Excluded paths are NOT scanned for secrets -- keep this list to generated +# and vendored content. Fixtures/snapshots are excluded as known +# false-positive sources; never park real credentials there. + +# Dependency trees and vendored code +(^|/)node_modules/ +(^|/)vendor/ + +# Build output +(^|/)dist/ +(^|/)build/ +(^|/)out/ +(^|/)\.next/ +(^|/)coverage/ + +# Lockfiles (integrity hashes trip entropy detectors) +(^|/)package-lock\.json$ +(^|/)yarn\.lock$ +(^|/)pnpm-lock\.yaml$ +(^|/)bun\.lockb$ +(^|/)bun\.lock$ +(^|/)poetry\.lock$ +(^|/)uv\.lock$ +(^|/)Cargo\.lock$ +(^|/)go\.sum$ + +# Minified/generated frontend assets and source maps +\.min\.js$ +\.min\.css$ +\.map$ + +# Test fixtures and snapshots +(^|/)__snapshots__/ +\.snap$ +(^|/)fixtures/ + +# Binary-ish assets +\.(png|jpe?g|gif|svg|ico|woff2?|ttf|eot|pdf|zip|gz)$