Programowanie defensywne w C++

Opublikowane: 02/12/2014

w kategorii Zasoby.

Programowanie defensywne dla większości programistów jest czymś dziwnym o czym mówią ciągle starzy ludzie z brodą bez powodu. Dla innych jest to wymaganiem przemysłowym. Dla innych filozofią.

Co to oznacza?

Jest to sposób programowania mając w pamięci prawo Murphy'iego:

Jeśli coś może pójść nie tak, pójdzie

Program powinien być odporny na kilka rzeczy:

  • niedoświadczony programista zmienia niepoprawnie strukturę programu - teraz funkcja otrzymuje NULL zamiast wskaźnika na bufor
  • z powodu zmiany API systemu operacyjnego funkcja która zwracała ścieżkę pliku dla aktualnie uruchomionego programu zwraca NULL
  • użytkownik wysunął z napędu CD/DVD z którego został załadowany program podczas działania
  • hacker próbuje złamać zabezpieczenia programu z zewnątrz stosując złośliwie sformułowane żądania

Jak to jest powiązane z C++?

C++ (prawdopodobnie ponieważ bazuje na C, implementuje wskaźniki i pracuje blisko sprzętu) jest podatne na niepoprawne sytuacje wskazane powyżej bardziej niż inne języki takie jak Java or C#. Bez odpowiedniej obsługi błędów i struktury kodu liczba błędów w kodzie jest niemal nieograniczona. Można łatwo wyprodukować 1-linijkowca z przynajmniej 3 błędami krytycznymi.

Reguły

Kilka przykładowych reguł:

  • nie używaj zwykłych wskaźników jeśli nie musisz, zamień na referencje lub sprytne wskaźniki
  • używaj tylko jednej instrukcji return dla ciała funkcji, dodatkowe instrukcje return dopuszczalne są tylko dla sprawdzania warunków wejściowych
  • używaj stałych zamiast literałów (użyj BUFFER_SIZE, a nie 100)
  • nie używaj wtórnych literałów jak (99 = buffer size - 1) zamiast bazowych literałów (tutaj 100)
  • używaj wyjątków zamiast kodów błędów
  • nigdy nie przechwytuj wszystkich wyjątków bez ich raportowania jeśli nie jesteś pewien (pewna) co zostało zgłoszone
  • nie pisz skomplikowanych jedno-linijkowców (wyrażeń zawierających kilka obliczeń, pętli i wyrażeń lambda z ubocznymi efektami) - podziel na nazwane pod-wyrażenia jeśli zbyt skomplikowane jeśli znaczenie nie jest oczywiste dla czytających kod
  • nie używaj niezabezpieczonych funkcji wejścia/wyjścia C/C++: gets, scanf, printf - zobacz F.1
  • wyrażaj siebie - nie bazuj swoich założeń na kolejności wykonania operatorów C++, zawsze używaj nawiasów (np. zamień "a || b > 2" na to co miałeś na prawdę na myśli, prawdopodobnie "a || (b > 2)" )
  • zawsze używaj sekcji "default" w instrukcji switch
  • zachowania niezdefiniowane (UB) powinny być traktowane jako błędy programistyczne (nie powinny przechodzić przez code review)

Cechy języka i biblioteki

ASSERT

Używaj ASSERT wszędzie gdzie to możliwe aby zabezpieczyć kod używając warunków wejściowych. Kontrole te wykonywane sa w czasie uruchomienia. Jednakże pamiętaj o tym że będą one wyłączone w trybie "release" więc jeśli ich potrzebujesz w wersji "release" programu - użyj wyjątków.

Zasoby:

static_assert

Asercje statyczne są rodzajem sprawdzeń które wykonywane są w czasie kompilacji. Zaimplementowane w Boost oraz w C++11. Mogą być użyte do sprawdzenia czy typ wejściowy ma odpowiednie cechy podczas kompilacji.

Zasoby:

type_traits

Cechy typów w C++ są kolekcją kontroli typów powiązanych np. z takimi cechami jak is_floating_point lub is_enum. Mogą być zaimplementowane bez pomocy Biblioteki Standardowej ale są także dostępne w C++11. Są one używane na przykład z static_assert lub enable_if.

Zasoby:

wskaźniki

W C++ zwykłe wskaźniki powinny być unikane i zastępowane przez sprytne wskaźniki lub referencje gdy tylko to możliwe.

Sprytne wskaźniki dostępne dzisiaj:

  • C++11: std::shared_ptr, std::unique_ptr (zastępnik dla std::auto_ptr)
  • Boost: boost::shared_ptr, boost::scoped_ptr
  • C++03: std::auto_ptr - nie polecane dla kontenerów STL

Jeśli potrzebujesz opcjonalnych parametrów to możesz użyć boost::optional zamiast przekazywania NULL jako wskaźnika.

wyjątki

Wyjątki często nie są do końca dobrze zrozumiane, i w konsekwencji całkowicie zabronione. Używanie wyjątków może być bolesne i czasami niemożliwe (np. w funkcjach DLL). Dodatkowo obsługa wyjątków może powodować zmniejszenie prędkości przetwarzania. A więc kiedy ich używać?

Zależy to od rodzaju oprogramowania z którym pracujesz - dla aplikacji biznesowych polecam stosowanie wyjątków do kontroli wszystkich nieprawidłowych sytuacji (jak np. funkcja zapisuje do pliku dla którego nie istnieje dysk lub katalog). Jest także dobra reguła którą stosuję: "zgłaszaj wyjątek tak wcześnie jak to możliwe". Jeśli możesz wykryć nieprawidłową sytuację wcześnie i nie ma szansy na obsługę tego błędu bez zgłoszenia wyjątku - zrób to. Zgłaszanie wyjątków w kodzie prowadzi do bezpieczniejszego kodu ponieważ wszystkie niepoprawne sytuacje mogą być wykryte wcześnie w procesie tworzenia oprogramowania.

Oczywiście jeśli możesz zignorować niepoprawną sytuację - zrób to. Nazywam to "uspokojeniem" kodu.

Przykład:

/// Przeczytaj dane z zewnętrznego źródła i wpisz do bufora
/// @param[in] buffer wskaźnik na bufor
/// @param[in] size rozmiar bufora
/// @return liczba przeczytanych bajtów
int readToBuffer(char *buffer, int size) {
   if (size <= 0) 
     throw ZlyRozmiar();
   ...  
}

Zamiast tego możesz "uspokoić" kod:

size_t readToBuffer(char *buffer, size_t size) {
   if (size == 0) 
     return 0;
   ...  
}

W drugiej wersj:

  • size_t nie pozwala na przekazywanie ujemnego rozmiaru (nie ma to sensu)
  • przekazanie bufora o zerowym rozmiarze nie jest błędem po prostu nic nie czytamy

Jest też podobna technika która może być pomylona z "uspokojeniem". Zobacz kod poniżej:

int readToBuffer(char *buffer, int size) {
    if (size < 0) {
        try {
            log->write("Buffer size not correct");
        } catch (...) {  
           // <-- "uciszanie" błędu, size było < 0 ale tego nie raportujemy
        }   
        return 0;             
    }
    ...
}

W tym przypadku (nazwijmy to "uciszaniem błędów") kiedy rozmiar jest nieprawidłowy i dziennik (log) nie skonfigurowany poprawnie nigdy nie zobaczymy błędu. Może to być niebezpieczne (ukrywa problemy).

Warunki Yoda

W C/C++ operator przypisania (=) może być zastosowany zamiast operatora równości (==). Z tego powodu niektórzy programiści stosują "warunki Yoda" które wyglądają dziwnie i nie naśladują standardowego toku myślenia ludzi:

if (100 == procent) {
  cout << "Mamy całą sumę!" << endl;
}

Używając takich dziwnych warunków nie jest polecane - lepiej jest sprawdzać ostrzeżenia kompilatora (VC++: C4706, GCC: dodaj -Wparentheses).

opcje kompilatora

Kilka kompilatorów w jednym dokumencie:

Visual Studio:

GCC:

  • -Weffc++: wykonaj sprawdzenia powiązane z książką "Efektywne C++"

Standardy wysokiej integralności C++

Jest kilka standardów które powinny być przestrzegane w aplikacjach gdzie bezpieczeństwo jest szczególnie ważne.

Zasoby

Książki

  • "Secure Coding in C and C++" - Robert C. Seacord, 2005
  • "Safe C++: How to avoid common mistakes" - Vladimir Kushnir

Inne zasoby

Zobacz również

Udostępnij

obserwuj