Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/scripts/check_outdated.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,18 @@
IDF_RELEASE_API = "https://api.github.com/repos/espressif/esp-idf/releases/latest"


ALLOWED_PREFIXES = ("https://components.espressif.com/api/", "https://api.github.com/")


def fetch_json(url):
# Only the two HTTPS APIs above: never file:// or another host
if not url.startswith(ALLOWED_PREFIXES):
raise ValueError(f"refusing to fetch {url!r}")
request = urllib.request.Request(url, headers={"User-Agent": "simplrssh-dependency-check"})
token = os.environ.get("GITHUB_TOKEN")
if token and "api.github.com" in url:
if token and url.startswith("https://api.github.com/"):
request.add_header("Authorization", f"Bearer {token}")
# noopengrep: mh.gitlab.python_urlopen_rule-urllib-urlopen, mh.opengrep.python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected - only the two https:// prefixes above # noqa: E501
with urllib.request.urlopen(request, timeout=30) as response:
return json.load(response)

Expand Down
11 changes: 7 additions & 4 deletions .github/workflows/security-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@ name: Security scan
# `sbom: false`, `zap: false`). It sits beside the Tests workflow (the release
# gate, with its firmware CVE audit); it does not gate releases.
#
# The pin below is moved by a pull request that sast-rules opens on each
# release; merging it is how this repository takes new rules and engines.
# scripts/sast.sh reads the same pin, so a local scan runs the same rules.
# Called @main, not pinned: sast-rules' main IS its release branch (every
# scan change pushed to main is released) and its workflows resolve their own
# rules from the commit they run at, so every run uses the newest release with
# no pin to move. The one exception to SHA pinning, by the owner's decision
# (test/guards/test_supply_chain.py, SAST_RULES_AT_MAIN). scripts/sast.sh
# resolves @main the same way, so a local scan runs the same rules.
#
# Needs: vars.AWS_REGION_EAST_1, secrets.AWS_ROLE (OIDC; pulls MicroHealth's
# FIPS images from ECR, writes the bucket), vars.SECURITY_REPORTS_S3_BUCKET
Expand All @@ -34,7 +37,7 @@ concurrency:
jobs:
security:
permissions: {contents: read, actions: read, security-events: write, id-token: write}
uses: MicroHealthLLC/sast-rules/.github/workflows/security-scan.yml@341cd54bc0aae9359fa56965281d45ad58ce812c # v2026.10.5
uses: MicroHealthLLC/sast-rules/.github/workflows/security-scan.yml@main # newest sast-rules release (main is its release branch)
with:
aws_region: ${{ vars.AWS_REGION_EAST_1 }}
s3_bucket: ${{ vars.SECURITY_REPORTS_S3_BUCKET }}
Expand Down
10 changes: 7 additions & 3 deletions AI_RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,10 @@ only while no SSH session is open; in a session they go to the server.
screen as printable ASCII (`portal::printable()`); on-screen strings in the source are ASCII.
- Keep the attack surface closed: station-only WiFi, IPv4, no listening sockets. Settings
live in `sdkconfig.defaults` with a comment explaining each one.
- CI actions are pinned to commit SHAs and the build image to a digest; keep it that way.
- CI actions are pinned to commit SHAs and the build image to a digest; keep it that way. The
one named exception, by the owner's decision: MicroHealth's `sast-rules` workflows are called
`@main` (its release branch), so the security scan always runs the newest release
(`SAST_RULES_AT_MAIN` in `test/guards/test_supply_chain.py`; nothing else may use it).

## Persistence

Expand Down Expand Up @@ -213,11 +216,12 @@ only while no SSH session is open; in a session they go to the server.
key material), audit (CVEs), network-surface (linked symbols, generated sdkconfig), secret-scan.
The code-quality workflow stays advisory.
- The **Security scan** workflow (`.github/workflows/security-scan.yml`) calls MicroHealth's
shared `security-scan.yml` (`MicroHealthLLC/sast-rules`, pinned by commit): SAST, Trivy,
shared `security-scan.yml` (`MicroHealthLLC/sast-rules`, called `@main` — its release branch, so every run
uses the newest release with no pin to move): SAST, Trivy,
SBOM and ZAP DAST, one report. Every scanner stays on (test/guards/test_supply_chain.py,
`SecurityScan`). Before pushing run `scripts/check.sh` (guards, host tests, then
`scripts/sast.sh` with the same rules); if SAST can't run locally, say so in the PR.
`scripts/sast.sh` is copied from sast-rules unchanged; never edit it. Suppress a finding
`scripts/sast.sh` is copied from sast-rules unchanged (it resolves the same `@main`); never edit it. Suppress a finding
only inline, with the full rule id and why it does not apply.
- Tests live in `test/`: `test/host` compiles hardware-independent firmware sources unchanged
for Linux against in-memory stand-ins for NVS, partitions and logging (`idf_stubs/`,
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ Saved passwords are encrypted on the device. For extra protection, set a PIN und
saved encrypted), or press Enter if it has none. If a key needs a passphrase you didn't
save, SimplrSSH asks for it when you connect.

Keys must be RSA in PEM format (the file starts with `-----BEGIN RSA PRIVATE KEY-----`). A key
starting with `-----BEGIN OPENSSH PRIVATE KEY-----` can be converted on your computer, keeping
Keys must be RSA in PEM format (the file's first line reads `BEGIN RSA PRIVATE KEY`). A key
whose first line reads `BEGIN OPENSSH PRIVATE KEY` can be converted on your computer, keeping
its passphrase: `ssh-keygen -p -m PEM -f mykey`. If you add a card while the device is running,
type `storage keys` to load them.

Expand Down
34 changes: 31 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,19 @@ A full sweep for malicious code, unexpected data flows and vulnerabilities.
| At boot the peripheral power pin (GPIO 10) and chip selects pulsed low while being configured, and the SD card was initialised immediately after power-on: a card talked to while its supply is coming up or dipping can damage its internal tables and become unreadable | Pins get their level before becoming outputs (no glitch); 250 ms settle before the SD card is touched; boot mount uses the same 10 MHz clock as the runtime mount |
| Reading the card could write to it (boot created `/ssh_keys`) | Mounts are read-only by default (a disk driver that refuses writes); only backup and deleting `openai.key` mount writable; the card is never formatted |

### SAST review (2026-10-14)

The security scan's first runs reported 37 Opengrep findings (Trivy: none). Each was checked
by hand:

| Finding | Outcome |
|---|---|
| A backup's header counts were read with `sscanf("%d")`: a huge number on the SD card overflowed `int` (undefined behaviour) | Parsed with `strtol`, 0 to 99999, else the backup is refused (host-tested) |
| `strncpy` into the WiFi config, `atoi` on menu numbers, unbounded `strlen` on a network header and an SD file name, `strlen` on constants | `snprintf`, `strtol`, `strnlen` with the field's limit, `sizeof` / `snprintf`'s length |
| `check_outdated.py` passed a built URL to `urlopen` (which also opens `file://`) | Only the two `https://` API prefixes are fetched; the call is suppressed inline |
| A private key in README.md | Documentation naming the PEM header line, not a key; reworded |
| 21 more: `memcpy` between fixed-size buffers, `strlen` on strings the firmware built, `fopen` on the SD card (TOCTOU) | Not applicable (fixed sizes, NUL-terminated by construction, FAT has no links or other users): suppressed inline with the full rule id and the reason |

### Usability change (2026-09-27)

Requiring a master PIN before any password could be saved meant networks silently weren't
Expand Down Expand Up @@ -105,13 +118,17 @@ a release waits for it to pass on the same commit. Security-relevant gates:
servers, OTA (including the Tab5 co-processor's), Bluetooth, mDNS or access-point code, must
link CA-bundle TLS verification, SSH host key checks and RAM-only WiFi credential storage,
and its generated sdkconfig must keep SoftAP, DHCP server, IPv6 and Bluetooth off.
- **Supply chain**: actions pinned by SHA and images by digest (enforced), components installed
- **Supply chain**: actions pinned by SHA and images by digest (enforced; the one named
exception is MicroHealth's own `sast-rules` workflows, called `@main` by the owner's decision
so the security scan always runs the newest release — `SAST_RULES_AT_MAIN` in
`test/guards/test_supply_chain.py`), components installed
only at the locked versions with verified hashes (each board has its own lock file), and a CVE
audit (esp-idf-sbom, NVD) of ESP-IDF, its libraries and the components of both images that
blocks on new high/critical findings.

Outside the Tests workflow, `.github/workflows/security-scan.yml` runs MicroHealth's shared
security scan (`MicroHealthLLC/sast-rules`, pinned by commit) on every push to `main` and
security scan (`MicroHealthLLC/sast-rules`, called `@main`: its release branch, so every run uses the
newest release with no pin to move) on every push to `main` and
`integration`, every pull request, nightly and on demand: SAST (Opengrep with MicroHealth's
rules), Trivy (dependencies, secrets and misconfiguration in the repository), a CycloneDX SBOM
and ZAP DAST, in parallel, ending in one `security-report` (each scanner's status and counts by
Expand All @@ -127,11 +144,22 @@ and never ingested, and a pull request's stay run artifacts (SAST there reports
new against the base).

The same SAST rules run locally: `scripts/sast.sh` (copied unchanged from
`MicroHealthLLC/sast-rules`; it reads the workflow's pin) and `scripts/check.sh`, which runs the
`MicroHealthLLC/sast-rules`; it resolves the workflow's `@main` reference to sast-rules' newest commit) and `scripts/check.sh`, which runs the
guard and host tests first. It needs docker or nerdctl logged in to MicroHealth's ECR. A
suppression is inline, with the full rule id and the reason it does not apply:
`// noopengrep: <rule-id> — <reason>`.

The repository is public: anyone can read, clone and fork it, and nothing more. Its GitHub
settings (not in the tree; change them only on purpose): issues, wiki, discussions and projects
are off; only collaborators can open pull requests; workflows from an outside contributor's
fork wait for approval; the default workflow token is read-only and cannot approve pull
requests (each workflow declares what it writes); a ruleset stops deletion and force-pushes of
`main` and `integration` (repository admins bypass it); secret scanning with push protection,
Dependabot alerts and private vulnerability reporting are on. The security scan needs
`secrets.AWS_ROLE` on this repository or shared with it by the organization (an org secret
limited to private repositories never reaches a public one), and the role's OIDC trust must
include `repo:MicroHealthLLC/SimplrSSH`.

Found while introducing these checks:

| Finding | Status |
Expand Down
1 change: 1 addition & 0 deletions dependencies.lock
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
dependencies:
espressif/button:
component_hash: a1f34e8ed2e61ffef1a230fd9477005c2db335c7e1f6a239f43e5f119dcbe1b0
Expand Down Expand Up @@ -252,6 +252,7 @@
type: service
version: 1.1.0
direct_dependencies:
- espressif/button
- espressif/esp_bsp_generic
- espressif/esp_codec_dev
- espressif/esp_lcd_touch_gt911
Expand Down
1 change: 1 addition & 0 deletions main/chat_app.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@
return key;
}
for (char c : key) {
if (!isalnum((unsigned char)c) && c != '-' && c != '_') {

Check warning on line 75 in main/chat_app.cpp

View workflow job for this annotation

GitHub Actions / Static analysis (cppcheck)

cppcheck useStlAlgorithm

style: Consider using std::any_of algorithm instead of a raw loop.
problem = "The key contains a character OpenAI keys don't use (only letters, digits, - and _).";
key.clear();
break;
Expand All @@ -88,10 +88,10 @@
void SSHTerminal::chat_load_settings()
{
nvs_handle_t h;
if (settings_nvs::open("chat", NVS_READONLY, &h) != ESP_OK) {

Check warning on line 91 in main/chat_app.cpp

View workflow job for this annotation

GitHub Actions / Security lint (flawfinder)

flawfinder

[2] (misc) open:Check when opening files - can an attacker redirect it (via symlinks), force the opening of special file type (e.g., device files), move things around to create a race condition, control its ancestors, or change its contents? (CWE-362).
return;
}
char model[64];

Check warning on line 94 in main/chat_app.cpp

View workflow job for this annotation

GitHub Actions / Security lint (flawfinder)

flawfinder

[2] (buffer) char:Statically-sized arrays can be improperly restricted, leading to potential overflows or other issues (CWE-119!/CWE-120). Perform bounds checking, use functions that limit length, or ensure that the size is larger than the maximum possible length.
size_t len = sizeof(model);
bool saved_model = nvs_get_str(h, "model", model, &len) == ESP_OK && model[0];
if (saved_model) {
Expand Down Expand Up @@ -119,7 +119,7 @@
void SSHTerminal::chat_save_settings()
{
nvs_handle_t h;
esp_err_t err = settings_nvs::open("chat", NVS_READWRITE, &h);

Check warning on line 122 in main/chat_app.cpp

View workflow job for this annotation

GitHub Actions / Security lint (flawfinder)

flawfinder

[2] (misc) open:Check when opening files - can an attacker redirect it (via symlinks), force the opening of special file type (e.g., device files), move things around to create a race condition, control its ancestors, or change its contents? (CWE-362).
if (err == ESP_OK) {
err = nvs_set_str(h, "model", chat_model.c_str());
if (err == ESP_OK) {
Expand Down Expand Up @@ -213,7 +213,7 @@

void SSHTerminal::chat_models_worker(void* param)
{
SSHTerminal* t = (SSHTerminal*)param;

Check warning on line 216 in main/chat_app.cpp

View workflow job for this annotation

GitHub Actions / Static analysis (cppcheck)

cppcheck cstyleCast

style: C-style pointer casting
std::string key, error;
openai::Models models;
esp_err_t err = vault::get(KEY_ID, key);
Expand Down Expand Up @@ -356,7 +356,7 @@

lv_obj_t* row = lv_obj_create(chat_view);
lv_obj_remove_style_all(row);
lv_obj_remove_flag(row, (lv_obj_flag_t)(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); // Drags scroll the view

Check warning on line 359 in main/chat_app.cpp

View workflow job for this annotation

GitHub Actions / build

Compiler -Wdeprecated-declarations

'void lv_obj_remove_flag(lv_obj_t*, lv_obj_flag_t)' is deprecated: Use the dedicated lv_obj_set_<flag>() setters instead, e.g. lv_obj_set_hidden(obj, false).

Check warning on line 359 in main/chat_app.cpp

View workflow job for this annotation

GitHub Actions / Compiler warnings

Compiler -Wdeprecated-declarations

'void lv_obj_remove_flag(lv_obj_t*, lv_obj_flag_t)' is deprecated: Use the dedicated lv_obj_set_<flag>() setters instead, e.g. lv_obj_set_hidden(obj, false).

Check warning on line 359 in main/chat_app.cpp

View workflow job for this annotation

GitHub Actions / build

Compiler -Wdeprecated-declarations

'void lv_obj_remove_flag(lv_obj_t*, lv_obj_flag_t)' is deprecated: Use the dedicated lv_obj_set_<flag>() setters instead, e.g. lv_obj_set_hidden(obj, false).
lv_obj_add_style(row, &s_row, 0);
lv_obj_add_style(row, kind == BUBBLE_USER ? &s_row_user : kind == BUBBLE_AI ? &s_row_ai : &s_row_info, 0);

Expand Down Expand Up @@ -555,7 +555,7 @@

void SSHTerminal::chat_worker(void* param)
{
SSHTerminal* t = (SSHTerminal*)param;

Check warning on line 558 in main/chat_app.cpp

View workflow job for this annotation

GitHub Actions / Static analysis (cppcheck)

cppcheck cstyleCast

style: C-style pointer casting
t->chat_job();
t->chat_ui_banner("");
t->chat_busy = false;
Expand Down Expand Up @@ -599,7 +599,7 @@
}

if (chat_job_voice) {
int16_t* pcm = (int16_t*)heap_caps_malloc(MAX_RECORD_SAMPLES * 2, MALLOC_CAP_SPIRAM);

Check warning on line 602 in main/chat_app.cpp

View workflow job for this annotation

GitHub Actions / Static analysis (cppcheck)

cppcheck cstyleCast

style: C-style pointer casting
if (!pcm || audio::mic_start() != ESP_OK) {
chat_recording = false;
heap_caps_free(pcm);
Expand Down Expand Up @@ -743,7 +743,7 @@
}
// Keep whole 16-bit samples across chunk boundaries
if (has_carry && len > 0) {
uint8_t pair[2] = {carry, data[0]};

Check warning on line 746 in main/chat_app.cpp

View workflow job for this annotation

GitHub Actions / Static analysis (cppcheck)

cppcheck constVariable

style: Variable 'pair' can be declared as const array
audio::speaker_write(pair, 2);
data++;
len--;
Expand Down Expand Up @@ -923,9 +923,10 @@
std::string problem = "No openai.key file at the top of the SD card.";
for (const char* name : {"openai.key", "openai.key.txt", "openai.txt"}) {
std::string path = std::string(sdcard::MOUNT_POINT) + "/" + name;
// noopengrep: mh.gitlab.c_misc_rule-fopen-open - single-user device, FAT on the SD card has no links or permissions to race; what is read is size-checked and parsed before use
FILE* f = fopen(path.c_str(), "rb");

Check warning on line 927 in main/chat_app.cpp

View workflow job for this annotation

GitHub Actions / Security lint (flawfinder)

flawfinder

[2] (misc) fopen:Check when opening files - can an attacker redirect it (via symlinks), force the opening of special file type (e.g., device files), move things around to create a race condition, control its ancestors, or change its contents? (CWE-362).
if (f) {
char buf[1024];

Check warning on line 929 in main/chat_app.cpp

View workflow job for this annotation

GitHub Actions / Security lint (flawfinder)

flawfinder

[2] (buffer) char:Statically-sized arrays can be improperly restricted, leading to potential overflows or other issues (CWE-119!/CWE-120). Perform bounds checking, use functions that limit length, or ensure that the size is larger than the maximum possible length.
size_t n = fread(buf, 1, sizeof(buf), f);
fclose(f);
std::string raw(buf, n);
Expand Down Expand Up @@ -993,7 +994,7 @@

case WizardStep::ChatModel: {
int n = !input.empty() && input.size() <= 2 && std::all_of(input.begin(), input.end(), ::isdigit)
? atoi(input.c_str()) : -1;

Check warning on line 997 in main/chat_app.cpp

View workflow job for this annotation

GitHub Actions / Security lint (flawfinder)

flawfinder

[2] (integer) atoi:Unless checked, the resulting number can exceed the expected range (CWE-190). If source untrusted, check both minimum and maximum, even if the input had no minus sign (large numbers can roll over into negative number; consider saving to an unsigned value if that is intended).
size_t shown = std::min(chat_models.size(), MODELS_SHOWN);
if (n >= 1 && (size_t)n <= shown) {
chat_model = chat_models[n - 1];
Expand Down
4 changes: 4 additions & 0 deletions main/idf_component.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
## IDF Component Manager Manifest File
## Direct dependencies are pinned to exact versions; the lock files (project root) pin the
## transitive ones: dependencies.lock for the T-Deck (esp32s3), dependencies.lock.esp32p4 for
Expand Down Expand Up @@ -27,6 +27,10 @@
version: '3.0.3' # Not used directly: esp_bsp_generic's led_indicator needs it; pinned
rules: # because the component manager re-solves and would take newer releases
- if: 'target == esp32s3'
espressif/button:
version: '4.2.1' # Not used directly: esp_bsp_generic needs it; pinned like led_strip
rules: # (the re-solve took 4.2.2, published 2026-10-14)
- if: 'target == esp32s3'
## M5Stack Tab5 (esp32p4)
espressif/m5stack_tab5:
version: '1.3.1' # Display (ILI9881C / ST7123 / ST7121), touch, IO expanders, codecs
Expand Down
18 changes: 12 additions & 6 deletions main/openai_client.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,10 @@ esp_err_t openai::chat(const std::string& key, const std::string& model, const s
return ESP_ERR_NO_MEM;
}

esp_http_client_handle_t client = open_post("chat/completions", key, "application/json", strlen(body));
bool ok = client && write_all(client, body, strlen(body));
// noopengrep: mh.gitlab.c_buffer_rule-strlen-wcslen - body is cJSON_PrintUnformatted's NUL-terminated output
size_t body_len = strlen(body);
esp_http_client_handle_t client = open_post("chat/completions", key, "application/json", body_len);
bool ok = client && write_all(client, body, body_len);
cJSON_free(body);
if (!client) {
error = "Can't reach OpenAI (check WiFi)";
Expand Down Expand Up @@ -196,8 +198,10 @@ esp_err_t openai::transcribe(const std::string& key, const std::string& model, c
uint8_t wav[44];
auto put32 = [&wav](int at, uint32_t v) { for (int i = 0; i < 4; i++) wav[at + i] = (v >> (8 * i)) & 0xFF; };
auto put16 = [&wav](int at, uint16_t v) { wav[at] = v & 0xFF; wav[at + 1] = v >> 8; };
// noopengrep: mh.gitlab.c_buffer_rule-memcpy-CopyMemory - fixed-size literals into fixed offsets of the 44-byte header
memcpy(wav, "RIFF", 4); put32(4, 36 + data_len); memcpy(wav + 8, "WAVEfmt ", 8);
put32(16, 16); put16(20, 1); put16(22, 1); put32(24, rate); put32(28, rate * 2); put16(32, 2); put16(34, 16);
// noopengrep: mh.gitlab.c_buffer_rule-memcpy-CopyMemory - fixed-size literal into a fixed offset of the 44-byte header
memcpy(wav + 36, "data", 4); put32(40, data_len);

std::string content_type = std::string("multipart/form-data; boundary=") + B;
Expand Down Expand Up @@ -240,8 +244,10 @@ esp_err_t openai::speak(const std::string& key, const std::string& model, const
if (!body) {
return ESP_ERR_NO_MEM;
}
esp_http_client_handle_t client = open_post("audio/speech", key, "application/json", strlen(body));
bool ok = client && write_all(client, body, strlen(body));
// noopengrep: mh.gitlab.c_buffer_rule-strlen-wcslen - body is cJSON_PrintUnformatted's NUL-terminated output
size_t body_len = strlen(body);
esp_http_client_handle_t client = open_post("audio/speech", key, "application/json", body_len);
bool ok = client && write_all(client, body, body_len);
cJSON_free(body);
if (!client) {
error = "Can't reach OpenAI";
Expand Down Expand Up @@ -377,8 +383,8 @@ esp_err_t openai::list_models(const std::string& key, Models& out, std::string&
std::vector<std::pair<long long, std::string>> found; // Every model, (created, id)
const char* start = body;
const char* end = body + len;
const char* data = "\"data\"";
const char* p = std::search(start, end, data, data + strlen(data));
static const char data[] = "\"data\"";
const char* p = std::search(start, end, data, data + sizeof(data) - 1);
while (p < end && found.size() < 400) {
const char* open = std::find(p, end, '{');
const char* close = std::find(open, end, '}');
Expand Down
2 changes: 1 addition & 1 deletion main/portal_client.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ static esp_err_t on_event(esp_http_client_event_t* evt)
}
Capture* c = (Capture*)evt->user_data;
if (strcasecmp(evt->header_key, "Set-Cookie") == 0) {
if (c->set_cookies.size() < MAX_SET_COOKIES && strlen(evt->header_value) <= MAX_COOKIE_BYTES + 512) {
if (c->set_cookies.size() < MAX_SET_COOKIES && strnlen(evt->header_value, MAX_COOKIE_BYTES + 513) <= MAX_COOKIE_BYTES + 512) {
c->set_cookies.push_back(evt->header_value);
}
} else if (strcasecmp(evt->header_key, "Location") == 0) {
Expand Down
4 changes: 2 additions & 2 deletions main/portal_menu.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,7 @@ bool SSHTerminal::portal_step_input(const std::string& raw_input, const std::str
case WizardStep::PortalPage: {
std::string choice = lower(input);
std::vector<portal::Item> items = portal::items(portal_page);
int n = is_number(choice) ? atoi(choice.c_str()) : -1;
int n = is_number(choice) ? (int)strtol(choice.c_str(), nullptr, 10) : -1;
if (choice == "0") {
wizard_done();
} else if (choice == "r") {
Expand Down Expand Up @@ -437,7 +437,7 @@ bool SSHTerminal::portal_step_input(const std::string& raw_input, const std::str

case WizardStep::PortalOption: {
portal::Field& f = portal_page.fields[portal_field];
int n = is_number(input) ? atoi(input.c_str()) : -1;
int n = is_number(input) ? (int)strtol(input.c_str(), nullptr, 10) : -1;
if (n >= 1 && (size_t)n <= f.options.size()) {
f.selected = n - 1;
} else if (n != 0) {
Expand Down
3 changes: 2 additions & 1 deletion main/sd_card.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ int sdcard::load_ssh_keys(SSHTerminal* terminal)
}

// Check if file has .pem or .PEM extension (case-insensitive)
size_t name_len = strlen(entry->d_name);
size_t name_len = strnlen(entry->d_name, sizeof(entry->d_name));
if (name_len < 5) {
ESP_LOGD(TAG, "Skipping file (name too short): %s", entry->d_name);
continue;
Expand All @@ -317,6 +317,7 @@ int sdcard::load_ssh_keys(SSHTerminal* terminal)
snprintf(filepath, sizeof(filepath), "%s/%s", keys_dir, entry->d_name);

// Open and read the key file
// noopengrep: mh.gitlab.c_misc_rule-fopen-open - single-user device, FAT on the SD card has no links or permissions to race; what is read is size-checked and parsed before use
FILE* f = fopen(filepath, "rb");
if (!f) {
ESP_LOGE(TAG, "Failed to open key file: %s", filepath);
Expand Down
5 changes: 5 additions & 0 deletions main/secret_vault.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ static bool derive_key(const uint8_t* password, size_t password_len, const uint8
mbedtls_md_hmac_finish(&ctx, u) == 0;

if (ok) {
// noopengrep: mh.gitlab.c_buffer_rule-memcpy-CopyMemory - both buffers are KEY_LEN bytes
memcpy(out, u, KEY_LEN);
for (uint32_t i = 1; i < iterations && ok; i++) {
ok = mbedtls_md_hmac_reset(&ctx) == 0 &&
Expand Down Expand Up @@ -193,6 +194,7 @@ static esp_err_t write_key_record(const std::string* pin, const uint8_t* data_ke
const uint32_t iterations = pin ? PIN_ITERATIONS : DEVICE_ITERATIONS;

uint8_t record[KEY_RECORD_LEN];
// noopengrep: mh.gitlab.c_buffer_rule-memcpy-CopyMemory - 4-byte magic into a KEY_RECORD_LEN record
memcpy(record, pin ? MAGIC_PIN : MAGIC_DEVICE, 4);
record[4] = iterations & 0xFF;
record[5] = (iterations >> 8) & 0xFF;
Expand Down Expand Up @@ -260,6 +262,7 @@ static esp_err_t create_new_vault()
esp_fill_random(data_key, KEY_LEN);
esp_err_t err = write_key_record(NULL, data_key);
if (err == ESP_OK) {
// noopengrep: mh.gitlab.c_buffer_rule-memcpy-CopyMemory - both buffers are KEY_LEN bytes
memcpy(s_data_key, data_key, KEY_LEN);
s_unlocked = true;
s_has_pin = false;
Expand Down Expand Up @@ -293,6 +296,7 @@ bool vault::init()

uint8_t data_key[KEY_LEN];
if (read_key_record(NULL, data_key) == ESP_OK) {
// noopengrep: mh.gitlab.c_buffer_rule-memcpy-CopyMemory - both buffers are KEY_LEN bytes
memcpy(s_data_key, data_key, KEY_LEN);
s_unlocked = true;
mbedtls_platform_zeroize(data_key, sizeof(data_key));
Expand Down Expand Up @@ -325,6 +329,7 @@ esp_err_t vault::unlock(const std::string& pin)
uint8_t data_key[KEY_LEN];
esp_err_t err = read_key_record(&pin, data_key);
if (err == ESP_OK) {
// noopengrep: mh.gitlab.c_buffer_rule-memcpy-CopyMemory - both buffers are KEY_LEN bytes
memcpy(s_data_key, data_key, KEY_LEN);
s_unlocked = true;
}
Expand Down
Loading
Loading